Accept Nano payments without a processor
Take payments in Nano on your own site, with no payment processor in between. Each checkout asks for its own exact amount, your node tells you when that amount has arrived and is confirmed, and the money is already in your wallet. Your server never holds a key, and there is no fee.
See it work first
This is how every payment on nano133.com works. Try one: Unlock opens an article for 1¢, and the arcade takes 1¢ rounds. Watch the amount: its last digits are different for every checkout.
What you need
- An address you control, from any Nano wallet. Payments go straight to it. Use a new address for your shop, so its history holds only your sales.
- A Nano node you trust, with its RPC URL. Its answers decide what you credit, so use your own node (a guide for running one is coming). A public node is fine to try this guide, but it can limit you, go away, or be wrong.
- A database with transactions (Postgres, Firestore, SQLite…), to lock amounts and record payments.
- Node.js 18 or newer to run the example.
1 Turn the price into raw
Nano amounts are whole numbers of raw: 1 XNO is 1030 raw. Work in raw everywhere, with big integers (BigInt), never with floating point. Round prices up to 0.0001 XNO, which leaves the last 26 digits free.
// 1 XNO = 10^30 raw. Round prices up to 0.0001 XNO (10^26 raw):
// the digits below that are free for the unique tail.
const STEP = 10n ** 26n;
const price = ((raw + STEP - 1n) / STEP) * STEP;Check: a price of 0.0271 XNO gives 27100000000000000000000000000 raw.
2 Give every checkout its own amount
Add a random tail of 1 to 999,999 raw to the price. That tail is less than a millionth of a cent, but it makes the amount unique, so the amount itself tells you which checkout was paid. You need no memo, no new address per order, and no key on your server.
// This checkout's own amount: the price plus 1 to 999999 raw.
const amount = price + BigInt(1 + Math.floor(Math.random() * 999_999));
// Lock it in your database until the checkout's expiry plus its grace.
// If the lock is taken, draw another tail.Store the amount as a lock in your database, inside a transaction, and keep it until the checkout expires plus a grace period (we use 15 minutes and 1 hour). While the lock exists, no other checkout may ask for that amount.
Check: open two checkouts for the same item at once: they never ask for the same amount.
3 Show the payment
Show a QR code and an “Open in wallet” link with this URI, and the exact amount and address as text for people who copy them:
nano:${yourAddress}?amount=${amount}Tell the buyer to send the exact amount. A wallet that opens the link sends it exactly; a person who types it by hand may round it, and then it pays nothing (step 7 covers that).
Check: scan the QR code with a wallet: it opens with your address and the exact amount filled in.
4 Find the payment
Poll your node every 2 seconds while the checkout is open. Look in two places:
receivable: sends to you that no wallet has received yet. Thethresholdskips anything smaller than the amount.account_historywithraw: if your wallet receives by itself, the send leaves the receivable list at once. A receive of exactly the amount points at the send in itslink.
{ "action": "receivable", "account": "<your address>", "count": "50",
"threshold": "<amount>", "source": "true" }
{ "action": "account_history", "account": "<your address>", "count": "50", "raw": "true" }For a busy shop, listen to your node's WebSocket confirmation topic for your address instead of polling, and keep the poll as the backup. Also let the page send the block hash when the buyer's wallet reports it: then you check that one block and skip the search.
Check: run the example below with --check on a send you already received: it answers paid.
5 Check that it really pays this checkout
Never credit a block because it showed up in a list. Ask for the block itself and check every rule:
{ "action": "block_info", "hash": "<send hash>", "json_block": "true" }
// It pays this checkout only when all of these hold:
b.subtype === "send"
b.contents.link_as_account === yourAddress
b.amount === amount // exactly, as a string of raw
seenAt >= checkout.start && seenAt <= checkout.expiry + grace // b.local_timestamp
b.confirmed === "true"The time window matters: once a lock expires, the same amount can belong to a later checkout, so an old send of that amount must not pay the new one. Credit only a confirmed block; a send the network has not confirmed can still lose.
Check: the same block checked against a different amount answers another amount, not paid.
6 Credit it once
In one database transaction, record the block hash as used and mark the checkout paid. If the hash is already recorded, refuse. One payment pays one checkout, once, even when two requests arrive at the same moment.
Then give the buyer what they paid for. If you give a receipt (a cookie or a link), sign it on the server and give it only to the browser that started the checkout.
Check: claim the same block for a second checkout: it answers “used” and credits nothing.
7 Fail closed, and plan for the odd cases
- Your node does not answer: answer “busy” (HTTP 503) and credit nothing. Never fall back to a node you do not trust for this decision.
- Prices come from your server, never from the browser.
- Rate-limit the checkout and claim routes per visitor: each claim costs your node a few calls.
- Wrong or late amounts pay nothing. Keep them, and send them back: our Late Payment Return tool does that by itself.
Check: stop your node and claim: the page says “try again”, and nothing is credited.
Run the whole thing
This script does steps 1 to 5 for one checkout. It needs no packages. Run it, pay the amount it prints from any wallet, and watch it see the payment:
NANO_RPC=https://your-node.example/rpc node accept-nano.mjs nano_1yourAddress… 0.05To test without paying, point it at a send you already received:
NANO_RPC=https://your-node.example/rpc node accept-nano.mjs --check <send block hash> <your address> <amount in raw>// Accept one Nano payment without a payment processor.
// From the nano133.com guide "Accept Nano payments without a processor".
// Node.js 18 or newer, no packages. MIT license.
//
// NANO_RPC=https://your-node.example/rpc node accept-nano.mjs <your nano_ address> <price in XNO>
// makes a checkout with a unique amount, prints the payment link, and
// waits (15 minutes) until a confirmed send of exactly that amount arrives.
//
// NANO_RPC=… node accept-nano.mjs --check <send block hash> <your address> <amount in raw>
// checks one block the way the checkout does, without a payment.
//
// Use your own node: its answers decide what you credit.
const RPC = process.env.NANO_RPC;
const RAW_PER_XNO = 10n ** 30n;
const STEP = 10n ** 26n; // prices round up to 0.0001 XNO; the unique tail lives below it
const OPEN_MS = 15 * 60_000;
async function rpc(body) {
const res = await fetch(RPC, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) });
const json = await res.json();
if (json.error) throw new Error(`${body.action}: ${json.error}`);
return json;
}
/** "0.05" XNO → raw, rounded up to 0.0001 XNO. */
function priceRaw(xno) {
const [whole, frac = ""] = String(xno).split(".");
const raw = BigInt(whole || "0") * RAW_PER_XNO + BigInt((frac + "0".repeat(30)).slice(0, 30));
return ((raw + STEP - 1n) / STEP) * STEP;
}
/** The price plus a random tail of 1 to 999999 raw: this checkout's own amount. */
const uniqueAmount = (price) => price + BigInt(1 + Math.floor(Math.random() * 999_999));
const toXno = (raw) => {
const s = raw.toString().padStart(31, "0");
const frac = s.slice(-30).replace(/0+$/, "");
return `${s.slice(0, -30).replace(/^0+(?=\d)/, "")}${frac ? `.${frac}` : ""}`;
};
/**
* Does `hash` pay this checkout? A send, to `to`, of exactly `amount`,
* first seen by the node inside the window, and confirmed.
*/
async function check(hash, to, amount, window) {
const b = await rpc({ action: "block_info", hash, json_block: "true" });
const subtype = b.subtype ?? b.contents.subtype;
if (subtype !== "send") return "not a send";
if (b.contents.link_as_account !== to) return "sent to another address";
if (b.amount !== amount) return "another amount";
const seen = Number(b.local_timestamp ?? 0) * 1000;
if (window && (seen < window.from || seen > window.until)) return "sent outside this checkout's window";
return b.confirmed === "true" ? "paid" : "not confirmed yet";
}
/** Sends of exactly `amount` to `to`: still receivable, or already received by a wallet. */
async function candidates(to, amount) {
const hashes = new Set();
const r = await rpc({ action: "receivable", account: to, count: "50", threshold: amount, source: "true" });
for (const [hash, v] of Object.entries(r.blocks || {})) if (v.amount === amount) hashes.add(hash);
// A wallet that receives by itself takes the send off the receivable list: look in the history too.
const h = await rpc({ action: "account_history", account: to, count: "50", raw: "true" }).catch(() => ({ history: [] }));
for (const e of h.history || []) if (e.subtype === "receive" && e.amount === amount) hashes.add(e.link);
return [...hashes];
}
if (!RPC) {
console.error("Set NANO_RPC to your node's RPC URL.");
process.exit(1);
}
const args = process.argv.slice(2);
if (args[0] === "--check") {
const [, hash, to, amount] = args;
console.log(await check(hash, to, amount, null));
} else {
const [to, price] = args;
if (!/^nano_[13][13456789abcdefghijkmnopqrstuwxyz]{59}$/.test(to ?? "") || !(Number(price) > 0)) {
console.error("Usage: node accept-nano.mjs <your nano_ address> <price in XNO>");
process.exit(1);
}
const amount = uniqueAmount(priceRaw(price)).toString();
const window = { from: Date.now() - 60_000, until: Date.now() + OPEN_MS };
console.log(`Pay exactly Ӿ${toXno(BigInt(amount))}`);
console.log(`Wallet link: nano:${to}?amount=${amount}`);
console.log("Waiting for the payment…");
const seen = new Set();
while (Date.now() < window.until) {
for (const hash of await candidates(to, amount)) {
const result = await check(hash, to, amount, window);
if (result === "paid") {
console.log(`Paid: block ${hash}. Record this hash so it pays only one checkout.`);
process.exit(0);
}
if (!seen.has(hash + result)) console.log(`${hash.slice(0, 12)}…: ${result}`);
seen.add(hash + result);
}
await new Promise((r) => setTimeout(r, 2000));
}
console.log("The checkout expired with no payment.");
}
The example keeps nothing between runs. In your shop, steps 2 and 6 belong in your database, inside transactions, as described above.
Or use the kit
Nano Checkout packages steps 1 to 6: unique amounts with locks, the payment check, one use per block, and signed receipts, with stores for Firestore or your own database. Install @nano133/checkout.
Sources
- Nano node RPC reference · receivable, account_history, block_info
- Nano node WebSockets · the confirmation topic
- nano-node on GitHub · the node software (BSD-3-Clause)
- nano.org · the Nano Foundation
- Nano Checkout · the same pattern as a package, from the nano133 Kit