xmr-pay
v1.0.1
Published
Sovereign Monero payments toolkit. Client-side payment links + QR (core), stateless on-chain proof verification (verify), signed fulfillment webhooks (webhook), and a zero-dependency checkout widget. No accounts, no API keys, no third parties in the payme
Maintainers
Readme
xmr-pay
Accept Monero on your own site. The money goes straight to your wallet. There is no company in the middle, no account to open, no fee paid to anyone, and nobody can freeze, see, or hold your funds but you.
xmr-pay is a small toolkit you run yourself: payment links and QR codes, an embeddable checkout widget, and trustless on-chain payment detection. It is code, not a service.
npm · MIT licensed · zero runtime dependencies · signed releases
Running a WooCommerce store?
There is a separate, standalone plugin built on this engine:
If you just want to accept Monero in a WordPress store with no code, start there. That plugin stands on its own (install it, paste your address, done) and this README is about the library underneath it. The two projects are independent; each links the other.
See it live (stagenet, no real money)
live.xmrpay.shop : configure it yourself and watch it verify a payment demo.xmrpay.shop : a full demo store, pay with free test XMR xmrpay.shop/demo.html : the checkout widget and the "prove you paid" flow
You do not need a dedicated server
This is worth being blunt about, because it is where most Monero tooling adds friction: confirming a Monero payment is, underneath, just reading public blockchain data and doing some math. So none of the ways to do it require a box that stays on 24/7.
Proof mode is a stateless function that runs on demand. It can live in a serverless function or one small route on the site you already have. Nothing is always-on. Watch mode runs wherever you already run code, against your own wallet-rpc or a built-in view-only scanner that needs no daemon at all. Inside WordPress, the plugin does both in pure PHP. No Node, no daemon, no separate process. WordPress's own cron and the buyer's checkout page trigger the check; the rest of the time nothing runs.
Why that is reasonably trustworthy (and where the limits honestly are)
It can see, it cannot spend. Detection only ever holds your view key. It can read incoming payments; it can never move your money. The key that spends funds is never asked for and never stored. The amount is proven, not claimed. Monero commits the real amount of every output on-chain. The verifier checks that commitment, so a forged or edited amount is rejected. It cannot be talked into seeing money that is not there. It fails closed. Several independent checks must all pass: the amount commitment, enough confirmations, the funds are not time-locked, and no transaction is counted twice. If anything is missing, or a node will not answer, the order stays unpaid. It never guesses "paid". The honest caveat: verification trusts the Monero node you point it at to tell the truth. A public node is fine for most sites. For serious money, point it at your own node, or require two nodes to agree. That one thing is on you, and the setting is right there.
The verification math is cross-checked against the reference Monero library and the whole money path is covered by an adversarial test suite, so "no server" does not mean "cut corners".
What you get
Your money, directly. Payments land in your wallet. xmr-pay never holds, routes, or can touch a cent. There is no xmr-pay in the payment path. No accounts, no API keys, no monthly fee. It is code you run, not a service you subscribe to. Privacy by default. Monero hides amounts and parties on-chain, and nothing in the buyer's browser is trusted to settle an order. Underpaid, or paid in two goes? Watch mode sums the payments, so the order finishes itself once the total adds up, and the buyer can top up to the same address.
The truths (please read before taking real money)
We would rather tell you the rough edges than have you find them with a customer.
Monero is irreversible, and the sender is hidden, so there are no automatic refunds. If you need to refund someone you send them XMR back by hand. That is the trade for having no chargebacks and no middleman. You are trusting a node to tell the truth. A single public node could lie, be slow, or go down. Fine for tips. For real revenue, run your own node or require two nodes to agree (it then refuses to confirm rather than trust one source). Few confirmations is fast but reversible. Accepting at zero confirmations is instant, but a payment can still vanish in a chain reorg. Use more confirmations for higher-value orders. This is your risk dial to set. A brand-new transaction can take a moment to verify on a public node. If a buyer submits a proof for a transaction still in the mempool, a public node may not serve it yet and the check comes back "try again", never a false "paid". It clears once the transaction is in a block. Your own node removes the wait. The browser is never trusted. Anything shown in a buyer's browser can be faked by that buyer. Goods are released only after your own server verified a real payment on-chain. Same rule as every serious payment system.
Install
npm i xmr-pay monero-ts # monero-ts only needed for server-side detectionThe network is explicit at every entry point. Default is mainnet; use stagenet to test with no real money:
Widget:
<xmr-pay network="stagenet" ...>(omit for mainnet). Verify:verifyPayment({ networkType: 'stagenet', ... }). Agent: theXMR_NETWORK=stagenetenv var (thenpx xmr-paywizard asks).
Run the agent in one command (non-custodial; it holds only your view key):
npx xmr-pay # setup wizard (address + view key + node), then it runsIt scans from the current block (no historical rescan), generates the token and
webhook secret, asks your settlement speed (instant 0-conf, fast 1 block,
secure 10 blocks), persists its wallet and orders, and prints the exact values to
paste into your store. npx xmr-pay start runs it again later.
How it works
| Module | Runs | Purpose |
|---|---|---|
| xmr-pay/core | browser + server | payment URIs (links), QR as SVG, per-order amount nonces |
| xmr-pay (verify) | your backend or serverless fn | re-verify a buyer's tx proof on-chain, trustless |
| xmr-pay/watch | your backend | auto-detection through your own monero-wallet-rpc |
| xmr-pay/scanner | your backend | view-only WASM scanner, auto-detection with NO wallet-rpc daemon |
| xmr-pay/agent | your backend | long-running order manager: per-order subaddress, summing, signed paid webhook |
| xmr-pay/config | offline + browser | signed merchant configs, tamper-evident addresses |
| xmr-pay/webhook | your backend | signed fulfillment webhooks to YOUR systems |
| widget/xmr-pay.js | browser | full checkout UI, one self-hosted file, zero dependencies |
Two detection modes, freely combined:
| | Proof mode (default) | Watch mode | |---|---|---| | Infra (yours) | a stateless verify endpoint, on demand | a long-running process you host | | Buyer effort | pastes txid + proof | none, just pays | | View key | not needed | yours, in-process (view-only) | | Partial / top-up auto-complete | manual (single-tx proofs) | automatic, sums transfers | | Best for | tips, a single product, lowest infra | a real store, installments, hands-off |
proof mode (no always-on process; your verify endpoint runs on demand):
buyer's browser: pays > wallet makes a tx proof > widget POSTs {txid, proof}
YOUR server: > verify endpoint > verifyPayment re-checks on YOUR nodes > paid
watch mode (the agent; no monero-wallet-rpc needed):
order > fresh subaddress > buyer pays > your agent scans and SUMS transfers
> paid (handles partial / split / top-up payments) > signed order.paid webhookThey share the same exact-math core, so a payment counts identically either way. Many shops run watch mode and keep proof as a dispute path. Watch mode is documented in full in docs/AGENT.md.
Checkout widget
One self-hosted file (widget/xmr-pay.js, ~98 KB, bundles its own QR encoder, no
external requests ever). Drop it in and you have a Monero checkout.
<script src="/xmr-pay.js"></script>
<!-- tips / donations, nothing else needed -->
<xmr-pay address="4YOUR_ADDRESS…" label="Buy me a coffee"></xmr-pay>
<!-- store checkout, detection against YOUR endpoint -->
<xmr-pay
address="4YOUR_ADDRESS…"
amount="0.050000004821"
order="ord_123"
verify-url="/api/verify-payment"
theme="light" lang="en"></xmr-pay>What the buyer gets: amount + QR (generated locally, with the exact tx_amount
prefilled so wallets can't be sent the wrong amount), click-to-copy address with a
highlighted fingerprint, "open in wallet" deep link, an always-there trust panel,
and a "paid? prove it" panel that submits txid + tx proof to your endpoint.
Attributes: address (required unless config is set), amount, label,
order, verify-url, redirect-url, lang (en/es), theme (light),
skin (brutal), config (base64 signed envelope), fingerprint/pubkey (pin
the signer).
Events: xmr-pay:paid, xmr-pay:result (CustomEvent, verify result in detail).
Buyer-error feedback (built in). Bad txid gives "that transaction ID should be 64 characters"; not a proof gives "paste the tx key or the proof block from your wallet", caught instantly before any server round-trip. Underpaid gives "Detected 0.1 XMR, send 0.2 more to complete" plus a fresh QR for exactly the missing amount (piconero-exact, no float drift). The proof box also smart-pastes a whole Feather block and picks out the txid + proof itself.
Skins. Default is a neutral, universal look (system sans, rounded, soft
shadows). skin="brutal" is the GOXMR brand look (monospace, square, hard shadow).
Both are driven by --xp-* CSS variables, so any brand can retheme without forking.
Payment links
A payment link is just a URL. Host examples/pay-link.html anywhere static and share:
https://your-site.com/pay-link.html#address=4…&amount=0.05&label=Invoice%2042core.makePaymentURI() builds the monero: URI (also what the widget's QR and
"open in wallet" use). The URIs are round-trip tested against the official wallet2
parser (what GUI, CLI and Feather run internally), including 12-decimal nonce
amounts and unicode descriptions, so they prefill cleanly across Feather, GUI, CLI,
Cake, Monerujo and Stack (mobile + desktop, Win/Mac/Linux).
Prefer the #fragment form for shared links; fragments never reach server logs or
proxies. Truly short URLs (/p/x7k2) need a lookup, so add a redirect route on your
own server rather than a third-party shortener that would track your buyers.
Buyer-side wallet instructions (Feather/GUI/Cake/CLI menu names, restored-seed caveat): docs/WALLETS.md.
Order creation (amount-nonce)
const { makeAmountNonce } = require('xmr-pay/core');
const amount = makeAmountNonce('0.05'); // '0.050000004821', unique per order
// store { order_id, amount } in YOUR db; render the widget with that amountThe random piconero tail makes each order's on-chain amount unique, so a proof structurally fits only its own order: a secondary anti-replay guard on top of your txid dedup. The added value is dust (default ≤ 0.000001 XMR).
Proof mode
The only server piece, and it is yours: stateless, runs on demand.
const { verifyPayment } = require('xmr-pay');
const r = await verifyPayment({
txid, proof, // what the buyer pasted (tx key or tx proof, auto-detected)
address: order.address,
amount: order.amount_xmr, // string keeps 12-decimal nonces exact
nodes: ['https://your-node:18081', 'https://fallback:18081'],
minConfirmations: 1, // 0 accepts mempool, your risk, your call
quorum: 1, // 2+ means independent nodes must agree
alreadyUsed: (txid) => db.txidSeen(txid),
});
// { paid, status, reason, receivedXmr, expectedXmr, shortfallXmr, confirmations,
// txid, nodesAgreed, overpaid } txid comes back normalized (lowercase)Full endpoint with anti-spam gates: examples/serverless.js. Drop it in Vercel/Netlify/Express; stateless, your orders table is the only state and it is already yours. One-click deploy template: docs/DEPLOY.md.
A freshly broadcast (mempool) transaction may not be retrievable from a public node
yet, so verification returns node-error (retryable, never a false paid) until the
tx is in a block. Run your own node to remove the wait.
If you already run monero-wallet-rpc, verifyPaymentViaRpc checks the same proofs
through it: same gates, same result shape, no WASM peer to install (so none of
monero-ts's transitive advisories; see SECURITY.md):
const { verifyPaymentViaRpc } = require('xmr-pay/watch');
const r = await verifyPaymentViaRpc({
url: 'http://127.0.0.1:18083', // your monero-wallet-rpc
txid, proof, address: order.address, amount: order.amount_xmr,
nodes: ['https://your-node:18081'], // for the time-lock gate if the wallet has no record of the tx
});Watch mode
Automatic detection: a fresh subaddress per order, payments summed (so partial
payments and top-ups auto-complete), the buyer submits nothing. Two transports: your
own monero-wallet-rpc, or a view-only WASM scanner with no daemon at all.
// no daemon: a view-only wallet from (address + view key), the agent does the rest
const { createScanner } = require('xmr-pay/scanner');
const { createPaymentAgent } = require('xmr-pay/agent');
const scanner = await createScanner({ primaryAddress, privateViewKey, networkType, nodes });
const agent = createPaymentAgent({ scanner, minConfirmations: 1, onPaid: (o) => fulfil(o) });
agent.start();
const order = await agent.createOrder({ id: 'ord_42', amount: '0.05' }); // returns { address, … }
const r = await agent.check('ord_42'); // { paid, status, receivedXmr, shortfallXmr, … }Each order gets its own subaddress, and a second order can never bind a subaddress already in use, so two orders can't credit the same payment. Full guide, the runnable HTTP service, config, and the trust model: docs/AGENT.md.
const { createWatcher } = require('xmr-pay/watch');
const watcher = createWatcher({ url: 'http://127.0.0.1:18083' });
const { address, index } = await watcher.newSubaddress('order ord_123');
const r = await watcher.checkOrder({ subaddressIndex: index, amount: order.amount_xmr });
// { paid, status: paid|partial|mempool|locked|pending, receivedXmr, shortfallXmr, txids }Per-order subaddresses replace the amount-nonce here (the address identifies the order). Time-locked outputs never count as paid. Keep wallet-rpc on localhost.
Webhooks
There is no xmr-pay server to call you. Your detection IS the webhook moment: when a payment settles, notify whatever needs to know (shop platform, shipping, Discord, Zapier), signed with your own secret:
const { sendWebhook, verifySignature } = require('xmr-pay/webhook');
if (r.paid) {
await sendWebhook(process.env.FULFILL_WEBHOOK_URL, {
event: 'order.paid', order_id, txid: r.txid, confirmations: r.confirmations,
}, { secret: process.env.FULFILL_WEBHOOK_SECRET }); // X-XMR-Pay-Signature: sha256=…
}
// receiver: verifySignature(rawBody, secret, req.headers['x-xmr-pay-signature'])Retries with backoff built in. (The agent fires this for you, once, on settle.) The
signed body carries an event_ts (unix ms): after verifying the signature, reject a
delivery whose event_ts is stale, and stay idempotent on order_id, so a replayed
webhook can't trigger a second fulfillment. The browser also gets an xmr-pay:paid
DOM event; treat it as UX only (a thank-you, a redirect), never the signal to release
goods.
Security and trust
The browser decides nothing. Fulfill on your server. A buyer can fake the
xmr-pay:paid event in devtools or point the widget at a fake server; it only fools
their own screen, and your server never verified a real payment.
Node trust. Verification is only as honest as the nodes you query. The default
quorum is 1 (fast, single node); set quorum: 2–3 for serious volume so
independent nodes must agree (it fails closed on disagreement, so availability then
rides on your nodes). For the highest confidence run your own monerod and use RPC
mode (verifyPaymentViaRpc against your own monero-wallet-rpc), which sidesteps the
bundled WASM wallet and its transitive dependencies entirely.
| Attack | Outcome |
|---|---|
| Buyer claims "I paid" with no proof | nothing to verify, rejected |
| Buyer fakes "paid" in devtools (forge the event, edit DOM, point verify-url at a fake server) | cosmetic, only their screen; your order stays unpaid. Fulfill server-side |
| Forged or tampered proof | fails cryptographic verification on-chain |
| Proof for a payment to someone else | proofs are address-bound, rejected |
| Reusing a real proof on another order | amount-nonce + alreadyUsed, returns replay/underpaid |
| Off by 1 piconero | integer-piconero compare, returns underpaid |
| Amount above Monero's max supply (uint64) | rejected; xmrToPico/atomicToPico enforce the on-chain ceiling, parity with monerod's parse_amount |
| Time-locked payment (unlock_time set, confirms but frozen) | raw tx fetched from the daemon; unlock_time ≠ 0 returns locked. Fails closed if no node returns the tx |
| A node lies | quorum: 2+ returns node-disagreement |
| A node or wallet-rpc is down, slow, or times out | node-error, transient and retryable, never a false paid. Distinct from invalid so you can tell "retry" from "reject"; the example endpoint answers 503 |
| Endpoint spam | gate on "order exists and pending" before any RPC |
| Double-submit race (same txid, concurrent) | claim the txid atomically with a UNIQUE constraint on tx_hash |
Fulfill server-side, never from the browser. Release goods only after your server returned
paidand wrote it to your order record. Same rule as Stripe.UNIQUEconstraint ontx_hashin your orders table closes the replay race thealreadyUsedcallback only narrows. UsemakeAmountNoncefor every order (proof mode), so a proof can't fit another order. ScaleminConfirmationswith value: 1 for small carts, 10 for high-value (reorg safety).minConfirmations: 0(mempool) is opt-in risk.quorum: 2for high-value orders, so two independent nodes must agree. Never takeaddress/amountfrom the request body; always your own order record (the examples do this). Your page is the trust root. If it is compromised the address can be swapped, so use a signed config + published fingerprint (below) for real-money stores.
Signing moves address integrity onto a key the merchant keeps off the web server, so a breach can serve the real signed config or a broken one, but cannot mint a new one for the attacker's address.
const { generateSigningKey, signConfig } = require('xmr-pay/config');
const key = generateSigningKey(); // keep privateKey offline
const env = signConfig({ address, amount: '0.05', networkType: 'mainnet' }, key.privateKey);
// env.fingerprint e.g. "2847-789f-a55a-bd90-1234-5678", publish where buyers can check<xmr-pay config="<base64 envelope>" verify-url="/api/verify-payment"></xmr-pay>The widget verifies the Ed25519 signature (WebCrypto, no extra dependency), uses the
signed address, and shows Signed · <fingerprint>. A "signed" config that fails
verification shows a red warning and no payable address. Pin a known signer with
pubkey="…" or fingerprint="…". With the fingerprint known out of band a buyer
catches an address swap even on a fully compromised page.
Verifying a payment asks one node for one transaction by its txid. The node learns
the txid and your IP/timing, not the amount or address (derived locally). That is
less than a normal wallet exposes. Close the exposure at the connection level: run
your own node (list it first in nodes), or egress over Tor / point at an .onion
node. nodes takes any URL, configuration not code.
Demo
A complete, deployable demo lives in demo/: a stagenet store checkout that
verifies a real payment on-chain, plus a mainnet tip widget with no backend.
cd demo && npm install && npm start # http://localhost:8780, click "Try it"Validated
187 offline checks plus a 92,006-case math fuzz, plus live stagenet validation.
Offline: input gates 40, core (links/QR/nonce) 22, signed configs 10, watch summing 14, webhooks 8, wallet-rpc verify 20, adversarial "chaos" 27, agent lifecycle 17, monerod amount parity 29, node-quorum 13, plus order-independence and byzantine-duplicate stress. The fuzz hammers the piconero math (shortfall, summing, round-trips) so paying the displayed difference always completes an order to the exact piconero, including the float traps (
0.1 + 0.2 = 0.3). The parity suite mirrors monerod's ownparse_amount(overflow ceiling, 13th-decimal, signs) so we never accept an amount the chain rejects. Live on stagenet: proof verify through a 13-case adversarial matrix (exact, underpaid/overpaid to the piconero, replay, address-bound rejection, malformed returnsinvalid, dead node returnsnode-error, 2-node quorum, at 0-conf and 1-conf), all through the unlock_time gate; the view-only scanner detecting a real payment via the view key alone; two real payments summed on one subaddress to complete an order; the agent end to end (per-order subaddress, settle, one-time signed webhook). Spot-checked against a real mainnet transaction key.
Donate
If xmr-pay saved you a payment processor's cut, a little Monero back is welcome, never required:
45sEohkyWYxAfHy8ekP7B34Bd3qhgrupcQfUQAHvfUWkfgqJhCA4QYLigrBg8G8TE4WggtMGpmjXrbmvepkWLec58KKLkm9Releases
The widget is a plain concatenation of widget/xmr-pay.part.js and the vendored
qrcode-generator (src/vendor/): no minifier, no timestamps, no npm install:
npm run build
shasum -a 256 widget/xmr-pay.js # must match SHA256SUMS in the releaseEach release ships SHA256SUMS plus a minisign signature. Public key (also
minisign.pub in this repo): RWSA/E4ogu5/1mQf2r66pkWK9fYBEeFdf2cvrjkhiALoXCWT3woSSRtH
minisign -Vm SHA256SUMS -P RWSA/E4ogu5/1mQf2r66pkWK9fYBEeFdf2cvrjkhiALoXCWT3woSSRtH
shasum -a 256 -c SHA256SUMSFrom npm the package is published with provenance (npm view xmr-pay --json | grep
provenance). If a signature or hash does not match, do not use the file, and report
it.
Docs
docs/AGENT.md : watch mode and the merchant agent (what it solves, API, trust model) docs/DEPLOY.md : one-click deploy of the verify endpoint docs/WALLETS.md : buyer-side wallet instructions per wallet docs/SUITE.md : how the pieces fit together CHANGELOG.md : release notes SECURITY.md : reporting, dependency advisories, "try to break it"
Acknowledgements
We stand on excellent open-source work. Give them a star:
monero-project: the protocol; our money-math parity suite mirrors
parse_amount's own unit tests. monero-integrations / monerophp (MIT): the pure-PHP ed25519, key-derivation and base58 primitives the WordPress-native verifier is vendored on. The breakthrough that made "verify in PHP" possible. kornrunner/php-keccak (MIT): Keccak-256 with Monero's padding, in pure PHP. monero-ts (woodser, MIT): the WASM Monero library powering the watch/proof paths, and our ground-truth reference for cross-checking the PHP verifier. qrcode-generator (MIT): the checkout widget's self-contained QR encoder. Inspiration: BTCPay Server's Monero plugin, MoneroPay, and AcceptXMR. We studied all three to match (and, on reorg-safety, double-spend and arithmetic, exceed) their detection model.
License
MIT, including the vendored qrcode-generator (c) Kazuhiko Arase, bundled so the widget makes zero external requests.
A GoXMR project, also available for WordPress / WooCommerce.
