TL;DR
An LNURLcash bearer note is a 32 byte secret. Whoever holds the string holds the money. That makes it the cleanest allowance I have found for an autonomous agent: hand it a note worth 100 sats and it cannot spend 101, because the bound is the asset rather than a policy you hope something enforces.
What you can actually do with it.
- Sell API calls for a twentieth of a penny, to callers who never sign up for anything. Thirty lines in front of your existing API, about fifteen minutes.
- Give an AI agent a spending limit it cannot argue its way past. Not a config value it might talk you into raising. It holds 100 sats, so it can spend 100 sats.
- Pay for things from a shell script. No wallet, no account, five HTTP GETs and one secret to keep hold of.
-
Take some money off this post right now. A live 21 sat note is on display at the mint. When somebody takes it, a fresh one appears the next morning, for a fortnight. Claiming needs nothing but
curlandopenssl. First come, first served.
This post builds both halves on your own machine, with real mainnet sats:
# 1. a paid API, on localhost, that charges 21 sats a call
node server.mjs
# 2. a bearer note, minted from a public mint
curl -s https://mint.forgesworn.dev/.well-known/lnurlp/mint
# 3. pay your own API with the note. no wallet involved.
NOTE=<64 hex> node pay.mjs
Total cost, 3 sats in fees, and the 21 sats you spend land back in your own wallet because you are the merchant. You need Node 22 or newer, a Lightning wallet, and around 100 sats. No account, no signup, no KYC, and no card network deciding whether a twentieth of a penny is a transaction worth having.
The hard part was never the payment
Paying for things over Lightning has been solved for years. The awkward part is giving something else the ability to pay, when that something is a loop you started and walked away from.
An API key means an account, a card on file, and a signup you cannot automate. A wallet connection means handing over a credential that can spend everything behind it, then trusting a budget setting to hold the line. A bearer note inverts it: the money is the credential, and there is nothing to revoke because there is nothing to authorise. The note either has 100 sats left in it or it does not.
Which is to say it behaves like cash. Cash is the only money we trust a child with, for exactly this reason: you do not hand a ten year old your card and configure a spending limit. You hand them a fiver.
The analogy breaks in three places, and the first two cost real money.
A banknote cannot be copied. A 32 byte secret can. Send one to somebody while keeping a copy and you are both holding the same money until one of you spends it, at which point the other holds a dead string. Not a smaller note, a dead one: the change is minted under a secret only the spender knows. Cash intuition says a thing you still possess is still yours, and here that is false.
A banknote is a claim on nobody. A note is a claim on whoever runs the mint, which here is me. Cash in your pocket survives my going out of business. A note does not.
And cash is unwatched, where a note is watched by exactly one party. There is no blinding, and a split hands the mint the parent secret and both child hashes in one request, so it can follow a note through every split it undergoes. It saw the payment that created the note and it pays the invoice you melt into. It never learns your name and never asks: pseudonymous, not anonymous. Strong against everybody except the mint, weak against the mint.
LNURLcash is dni's draft for exactly this, built on plain LUD-03 and LUD-06 rather than a new protocol. A mint's node holds the sats. Whoever knows a note's secret owns its value. There is no blinding, no keysets, no proof selection, no reconciling a wallet full of denominations. What there is instead is a new secret after every single spend, which is the one thing you have to keep hold of.
A note is also a link, lnurlw://mint…/w?k1=<the secret>, and the link is the money, like a gift card scratch code for Bitcoin. You can text it, print it as a QR code, or read it out loud (please do not read it out loud). Four verbs cover everything a cashier can do with it:
| You want to | The verb |
|---|---|
| break a £20 into two £10s | split |
| combine shrapnel into one note | merge |
| swap a note for a fresh secret | rotate |
| cash out to any Lightning wallet | melt |
The entire spend path is five HTTP GETs, which is the part I want to show you.
Why not Cashu
The obvious question, since Cashu has done bearer ecash on Lightning for years, with the unlinkability this lacks. The answer is reach. A note is an ordinary LUD-03 withdrawRequest, so wallets that have never heard of LNURLcash already accept one: the faucet note at the bottom of this post works in Zeus, Phoenix, Alby and Wallet of Satoshi right now. A Cashu token needs a Cashu wallet first, and "first install this" is where most people stop. Spending needs nothing beyond sha256 and a random number generator, and there is less to get wrong: no proof selection, no denominations, no keyset rotation, so the class of bug where a client quietly destroys value has fewer members in it.
The cost is the privacy described above, plus the years of review Cashu has had and this has not. If you need unlinkability, use Cashu. If you need a note anyone can already receive and a program can spend with fetch, this is the trade.
Part 1: a paid API in 30 lines
The API charges 21 sats for a number between one and six. It is a terrible product. That is the point: nothing here is specific to what you are selling.
Install:
npm init -y && npm pkg set type=module
npm install express @forgesworn/toll-booth
server.mjs:
import express from 'express'
import { Booth } from '@forgesworn/toll-booth'
import { nwcBackend } from '@forgesworn/toll-booth/backends/nwc'
// The API. It knows nothing about payments.
const api = express()
api.get('/api/dice', (_req, res) => res.json({ roll: 1 + Math.floor(Math.random() * 6) }))
api.listen(4444)
// The gate in front of it.
const booth = new Booth({
adapter: 'express',
backend: nwcBackend({ nwcUrl: process.env.NWC_URI }),
pricing: { '/api/dice': 21 },
upstream: 'http://localhost:4444',
defaultInvoiceAmount: 21,
strictPricing: true,
rootKey: process.env.ROOT_KEY,
dbPath: './toll-booth.db',
})
const app = express()
app.use(express.json())
app.get('/invoice-status/:paymentHash', booth.invoiceStatusHandler)
app.post('/create-invoice', booth.createInvoiceHandler)
app.use('/', booth.middleware)
app.listen(3000, () => console.log('paywall on http://localhost:3000'))
toll-booth is L402 middleware: it mints the macaroon, creates the invoice, and proxies to your API once the caller has paid. The backend is the only line that cares where your sats live. Five ship with it (phoenixd, LND, CLN, LNbits, NWC), and any NWC string with make_invoice and lookup_invoice will do, with nwc-lnd-bridge making one from an LND node. Or write your own: a backend is two methods, so any custodial wallet with a REST API can be the till. So little is needed because L402 authenticates on the preimage itself. Your server never has to ask its own node whether it got paid, which is also why this works when the caller pays by a route your node has never heard of.
Run it:
export ROOT_KEY=$(openssl rand -hex 32)
export NWC_URI='nostr+walletconnect://...'
node server.mjs
And knock on the door:
curl -i http://localhost:3000/api/dice
If something already holds port 3000, change it in both files, because pay.mjs below defaults to the same address. Otherwise your request reaches whatever else is listening, you get a cheerful 200, and you spend a while wondering why the paywall is giving the API away. Ask me how I know.
HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="AgEKdG9sbC1ib290aAJCAACnTH6m1M8mRCWdR0WZa0NElpi0...",
invoice="lnbc210n1p4gyyj5pp5qw5svm907k5w8..."
That is L402: a macaroon plus an invoice. Pay the invoice, and the preimage your wallet gets back is the second half of the credential. Send both and you are in. The response body carries the same challenge as JSON, which is what our script will read.
Part 2: a note, for real, on mainnet
mint.forgesworn.dev is a small public LNURLcash mint I run on a dedicated box. It is deliberately tiny and explicitly experimental, so mint 100 sats for this and no more. There is a no-warranty notice on the front page and I mean it.
The mint speaks LUD-06, so minting is a payRequest, found at the LUD-16
lightning-address path for mint@mint.forgesworn.dev:
curl -s https://mint.forgesworn.dev/.well-known/lnurlp/mint
{
"tag": "payRequest",
"callback": "https://mint.forgesworn.dev/p/cb",
"minSendable": 12000,
"maxSendable": 250000000,
"metadata": "[[\"text/plain\", \"Mint an lnurlcash bearer note...\"]]"
}
Ask the callback for an invoice, in millisatoshis:
curl -s 'https://mint.forgesworn.dev/p/cb?amount=100000' | jq -r .pr
Pay that invoice with any wallet. Now the good bit:
The preimage of that payment is the note.
Not a receipt for the note, not a key that unlocks it. The 64 hex characters your wallet shows under payment details are the bearer secret, and anyone reading them over your shoulder owns the 98 sats they are worth. Alby, Zeus, Phoenix and LNbits all show you a preimage. If yours will not, dni's serverless wallet at dni.github.io/lnurl-wallet does the whole flow in the browser and keeps notes encrypted locally.
One caveat that matters. The mint's node saw that preimage when it settled the invoice, which makes the mint a permanent prior holder of the note. Rotate into a fresh secret before you rely on it. Conveniently the first thing our payment script does is a split, and a split rotates: the client picks the new secrets, and the mint learns only their hashes, one-way fingerprints it can verify but never reverse.
Fees, so you can check my arithmetic against your balance. The mint withholds 1 sat flat plus 0.1%, rounded up to the whole sat, so 100 sats in gives a note worth 98 sats. At this size the rounding is most of the fee: a 15 sat mint and a 23 sat mint are both charged 2 sats.
Part 3: pay your own API with the string
Here is the whole thing. No dependencies beyond Node.
pay.mjs:
import { createHash, randomBytes } from 'node:crypto'
const API = process.argv[2] ?? 'http://localhost:3000/api/dice'
const MINT = 'https://mint.forgesworn.dev'
const NOTE = process.env.NOTE // 64 hex characters
const get = async (url) => {
const body = await (await fetch(url)).json()
if (body.status === 'ERROR') throw new Error(body.reason)
return body
}
const sha256 = (hex) => createHash('sha256').update(Buffer.from(hex, 'hex')).digest('hex')
const fresh = () => randomBytes(32).toString('hex')
// 1. Ask the API what it wants.
const challenge = await fetch(API)
if (challenge.status !== 402) throw new Error(`expected 402, got ${challenge.status}`)
const { invoice, macaroon, payment_hash, amount_sats } = (await challenge.json()).l402
const needMsat = amount_sats * 1000
console.log(`wants ${amount_sats} sats`)
// 2. Split the note down to exactly that amount. The mint refuses any melt
// whose invoice does not match the note's value to the millisatoshi.
// WRITE THESE TWO SECRETS DOWN BEFORE THE REQUEST GOES OUT.
const paySecret = fresh(), changeSecret = fresh()
console.log(`pay note ${paySecret}\nchange note ${changeSecret}`)
const parent = await get(`${MINT}/w?k1=${NOTE}`)
if (parent.maxWithdrawable <= needMsat + 1000) throw new Error('note too small') // + split fee, and change cannot be zero
await get(`${parent.callback}?k1=${NOTE}&amount=${needMsat}` +
`&h=${sha256(paySecret)}&h2=${sha256(changeSecret)}`)
// 3. Melt the exact note against the API's invoice. The mint pays it on the
// Lightning network as a background task and hands back a URL to watch.
const exact = await get(`${MINT}/w?k1=${paySecret}`)
const melt = await get(`${exact.callback}?k1=${paySecret}&pr=${invoice}`)
// 4. Poll until it settles. LUD-21 verify returns the preimage of the invoice
// the mint just paid, which is precisely what L402 asked us for.
let preimage
for (let i = 0; i < 180 && !preimage; i++) { // usually 2s. once, for me, 60s.
const v = await get(melt.verify)
if (v.settled && v.preimage) preimage = v.preimage
else await new Promise(r => setTimeout(r, 1000))
}
if (!preimage) throw new Error(`melt did not settle. keep polling ${melt.verify}`)
if (sha256(preimage) !== payment_hash) throw new Error('preimage does not match the invoice')
// 5. Spend the credential.
const paid = await fetch(API, { headers: { Authorization: `L402 ${macaroon}:${preimage}` } })
console.log(paid.status, await paid.json())
const change = await get(`${MINT}/w?k1=${changeSecret}`)
console.log(`change: ${change.maxWithdrawable} msat in ${changeSecret}`)
NOTE=<the preimage from Part 2> node pay.mjs
wants 21 sats
pay note 9f2c...
change note 41ba...
200 { roll: 4 }
change: 76000 msat in 41ba...
Five GETs to the mint, plus one to read the change back, two to the API, and a number between one and six that cost 21 sats. Those sats are now in your own wallet, because the invoice the mint paid was yours.
That console warning above the split is not decoration. The mint keys the new notes to the hashes of secrets it never learns, so a crash between generating those secrets and getting them onto disk destroys the money outright. A real client persists both secrets to disk before the request and reconciles on the next run, which is most of what separates a demo from a wallet.
A note is not a balance
This is the part that catches everybody, including me, so it is worth showing rather than describing. Run the script twice more, first with the same note, then with the change it printed:
$ NOTE=e3f1... node pay.mjs # the note you started with
wants 21 sats
pay note 7d05...
change note 8c93...
Error: Note already spent.
$ NOTE=41ba... node pay.mjs # the change from the run above
wants 21 sats
pay note b620...
change note 5ae4...
200 { roll: 2 }
change: 54000 msat in 5ae4...
Every spend replaces your note. Nothing was debited, because there is no account to debit. The mint burned the note you presented and minted a fresh one for the change, keyed to a secret you generated seconds earlier, and the secret you started the day with is now worth nothing. The change note is your wallet. Lose that string and the money is gone, even though you never spent it.
So a 98 sat note is four dice rolls, and it is also four different notes: 98 becomes 76 becomes 54 becomes 32 becomes 10, a sat of split fee each time, with the 10 sat remnant mergeable into your next note for a 1 sat refund. That is the loop Part 4 automates. An agent writes each new secret to its note store as it goes, which is why a burst of paid calls looks like a balance going down when it is really a chain of notes, each replacing the last.
Why the server needed no changes at all
Look at what Part 1 did to support any of this: nothing. It issued an ordinary BOLT-11 invoice and waited. A melt is a normal Lightning payment, routed over the public network to whoever the invoice names, so the paywalled API cannot tell a bearer note from a phone. This works against Aperture, against toll-booth, against anything speaking L402, with no cooperation from the far end.
The pivot is LUD-21, the verify extension. When the mint tells you a melt has settled, it hands back the preimage of the invoice it paid. L402 wants a macaroon and a preimage. The mint's proof of payment and the API's proof of payment are the same 32 bytes, so the two protocols join up without either of them being designed for the other.
Worth noticing that your API stayed on localhost throughout. Never reachable from the internet, and it still got paid, because the only thing that had to be reachable was your wallet.
Part 4: now give it to an agent
The script above is the protocol. In practice you let the agent run it. 402-mcp is an MCP server that lets an AI agent discover, pay for and consume L402 APIs, with LNURLcash as one of its wallet providers:
claude mcp add 402-mcp -- npx 402-mcp
export LNURLCASH_NOTES=~/.402-mcp/notes.json # encrypted at rest
export MAX_AUTO_PAY_SATS=100
Then ask it to fetch something that costs money and watch it split, melt, verify and authenticate on its own. The note store is the wallet; when the notes are gone, the agent stops, and no amount of creative prompting gets it more.
That is the property I care about. MAX_AUTO_PAY_SATS is a policy, and policies are code someone can be argued out of. A 100 sat note is not a policy. It is a hundred sats.
Handing a note over is handing it over, and it is the copy you kept that will mislead you. The moment the agent buys its first dice roll, your copy is dead. Not reduced to 77 sats. Dead. Up to that first purchase your copy could have taken the whole note back; after it, nothing, with no state in between. Which is the honest footnote to "there is nothing to revoke" further up: a copy is a revoke while it lasts, it is also indistinguishable from theft, and the agent has no way to check you deleted yours.
The production version handles what the demo skips: notes that were already spent by another holder of a copy get dropped and the next one is tried, melts whose outcome never came back are reconciled against the mint before the next payment, and an unproven preimage is never treated as payment. Bearer assets are unforgiving, and every one of those cases is one I hit in testing rather than one I imagined.
Now look at what that price buys
The whole round trip cost 3 sats: 2 to mint, 1 to split, nothing to melt. And twenty one sats is about a penny, at the £47,000 or so a bitcoin was worth the day I wrote this. Sit with that for a second, because it is the part that gets waved past.
A card cannot represent this transaction at all. UK Stripe is 1.5% plus 20p, so the fee on that penny is twenty times the sale. There is no volume at which that inverts, because the 20p is fixed. Every business model that needs a sub-penny price gets built as a subscription instead, not because subscriptions are better but because the payment rail refuses. A monthly minimum is what a payments industry looks like when its floor is 50p.
Here is the rail on this side of the fence:
| Card | This | |
|---|---|---|
| Smallest sensible charge | about 50p | 1 sat, a twentieth of a penny |
| Fee on a 21 sat call | not possible | 3 sats, and falling fast with size |
| Time to first payment | days, after KYC | minutes, no account anywhere |
| What the seller signs up for | merchant account, bank, identity | npm install |
| What the buyer signs up for | card, billing address, name | nothing. they hold a string |
| Chargebacks | 120 days of exposure | none. payments are final |
| Settlement | T+2 | seconds |
| Who can be a customer | humans with legal identity | anything that can make an HTTP request |
Yes, 3 sats on a 21 sat payment is 14%, a worse percentage than any card, and also a fee on a transaction the card network cannot process at any price. It collapses as the amount grows: a real 4,900 sat melt cost 0.22%. Fixed costs dominate at the bottom and vanish above it, the opposite shape to a 20p floor that never moves.
The row I care about most is the last one. Every rail before this one required the buyer to be a person, or to borrow a person's credentials. Software could not have a bank account, so it was given API keys belonging to someone who did. A bearer note is the first thing on this list a program can simply have, and the reason your API can sell to a customer that will never fill in a form.
The honest limits
- The mint is custodial. A bearer note is a claim on my node. If it burns down, your note is a string that no longer means anything. Mint 100 sats, not 100,000.
- The mint is young, and your wallet may not find it. Two channels, days old. Some wallets pay it first time; one large custodial wallet timed out in pathfinding without ever trying the mint's channels. Reachability, not liquidity. If minting fails, try a different wallet before assuming anything is broken.
- The spec is a draft. LUD-25 is an open PR. Things will change, and some of the changing is me sending fixes upstream.
-
The secret travels in the URL. Every operation is
?k1=<the money>, and query strings turn up in shell history, browser history, and access logs. Treat a note as a password that has to be sent as a GET parameter, because that is exactly what it is. - A copy is a spend, and it spends the whole note. The loser of the race loses the lot, because the change is minted under the winner's secret. Rotate after minting, and treat every local balance as a cache the mint is free to contradict.
- The melt is asynchronous, and the verify URL is the receipt. The mint pays in the background, usually in seconds, once in a minute. Stop polling too early and the payment still lands but the preimage that proves it lives only behind that URL. Store it next to the note, not in a local variable.
-
This is a demo.
pay.mjsfits in a blog post because it leaves out crash safety. Use the real client for anything that matters.
The short version
Imagine money that works like a magic word. There is a piggy bank on the internet that gives the money inside to the first person who says the word. No names anywhere, so giving someone the money is just whispering the word to them, and the whisper is the whole handing over.
If I whisper it to you and secretly remember it, we both know the word, and whichever of us says it to the piggy bank first gets everything. The slow one gets nothing. So giving this money away means properly forgetting it, like passing a parcel you cannot keep a corner of.
Spending is odd too. No coins come back. You think up a brand new word for whatever is left, and you tell the piggy bank only the word's fingerprint, a scramble that cannot be run backwards, the way a smoothie does not turn back into strawberries. The bank can recognise the word when it hears it, but it never hears it early, and the old word stops working forever. Every time you spend, your money gets a new name that only you have ever said.
And you can give the word to a robot. The robot can buy things all by itself, and it can never spend more than the word is worth, however cleverly it asks, because there is no more money inside the word.
At the bottom of this post is a link to today's magic word, drawn as a square code a camera can read and written as letters a computer can read. It is not a picture of the money. It is the money. When somebody takes it, the piggy bank puts up a new word the next morning.
Here is some money
There is one live bearer note on display at mint.forgesworn.dev/faucet, drawn up as the banknote it genuinely is. Not a voucher, not a code to redeem, not a referral scheme. Whoever takes it holds the value, and once it is taken a fresh note replaces it the next morning. Twenty one sats a day for a fortnight, the price of one dice roll each morning. If today's is already dead, tomorrow exists.
Scan it
Open the faucet and point any Lightning wallet at the QR in the note's security window. Zeus, Phoenix, Alby, Wallet of Satoshi, they all speak this, because a note's withdraw URL is an ordinary LUD-03 withdrawRequest and the QR is that URL in a form a camera can read. Your wallet hands the mint an invoice, the mint pays it, the row is marked spent. That is a melt, the same operation Part 3 used to buy a dice roll.
Or take it with the secret word
Withdrawing moves the money into a wallet. If you would rather hold it, as a bearer note under a secret only you know, you never need a wallet at any point. Today's secret is one curl away, served as base64 rather than raw hex so a scraper grepping the wire for 64 hex characters finds nothing. That encoding is not security. It buys humans a head start over the laziest bots, nothing more.
B64=$(curl -s https://mint.forgesworn.dev/faucet/today.txt)
NOTE=$(printf %s "$B64" | openssl base64 -d -A | xxd -p -c 32)
curl -s "https://mint.forgesworn.dev/w?k1=$NOTE"
A live note answers with its value in millisatoshis. A dead one says Note already spent., which means somebody was faster than you, and that there is another note in the morning. That is not the demo failing. That is the demo.
To take it, split it into secrets only you know. No wallet, no account, no signup, nothing beyond what is already on your machine:
VALUE=<the msat number the mint just gave you>
TAKE=$((VALUE / 2)) # or all but 1001 msat, if you want the lot
NEW=$(openssl rand -hex 32); OTHER=$(openssl rand -hex 32)
echo "$NEW"; echo "$OTHER" # WRITE THESE DOWN BEFORE RUNNING THE NEXT LINE
H=$(printf %s "$NEW" | xxd -r -p | openssl dgst -sha256 -r | cut -d' ' -f1)
H2=$(printf %s "$OTHER" | xxd -r -p | openssl dgst -sha256 -r | cut -d' ' -f1)
curl -s "https://mint.forgesworn.dev/w/cb?k1=$NOTE&amount=$TAKE&h=$H&h2=$H2"
{"status":"OK"} and it is yours, held now as two notes the mint knows only by their hashes. That is the whole ceremony for taking custody of money on the internet: two random numbers and a GET. The 1 sat split fee is the only cost, and you never told anyone your name. You did show the mint an IP address, so call it pseudonymous rather than anonymous.
The warning in that snippet is not decoration. If the request goes out and those secrets have not reached your disk, the money is gone and nobody can recover it. Not me, not the mint, nobody.
When you want ordinary sats instead, hand the mint an invoice from any wallet at the same withdraw callback, which is the melt from Part 3. Or spend it straight at an L402 API and skip the wallet entirely, which was the point.
Try it
- Mint: mint.forgesworn.dev (experimental, small notes only)
- Wallet: dni.github.io/lnurl-wallet
- Spec and mint implementation: dni/lnurl-mint
- Paywall middleware: forgesworn/toll-booth
- Agent client: 402-mcp
If you build something with it, or if a note of yours goes missing, tell me. Both are useful at this stage.
Top comments (0)