{"slug": "lightning-network-l402-paywall-12-steps-90-min-2026", "title": "Lightning Network L402 Paywall: 12 Steps, 90 Min [2026]", "summary": "Lightning Labs released Aperture v0.6.0 on November 12, 2026, alongside pi402, a tool that lets coding agents pay for AI inference in bitcoin, according to Lightning News. Aperture is a reverse proxy that answers unpaid API requests with HTTP 402 and a Lightning invoice, then validates the L402 macaroon-and-preimage pair before forwarding paid requests to a backend; Lightning Labs formalized the protocol as bLIP-0026 and said on March 11, 2026 that L402 was production-ready. A tutorial lab using two LND nodes, a Bitcoin Core 32.1 backend, a Python API on port 8000 and Aperture on port 8081 charges 25 sats per token on a private regtest network and takes about 90 minutes to build.", "body_md": "Lightning Labs announced Aperture v0.6.0 on November 12, 2026, alongside pi402, a tool that lets coding agents pay for AI inference in bitcoin, according to [Lightning News coverage of the launch](https://lightning.news/pi402/). Aperture is the seller-side piece. It sits in front of your API, answers unpaid requests with HTTP 402 and a Lightning invoice, and lets paid requests through. Put one in front of a plain Python API on a private regtest network, charge 25 sats per token, and pay it from a second node.\n\nRegtest keeps the stakes at zero. You mine blocks on demand, open channels in seconds, and break the paywall as often as you like before a real satoshi moves. If you have never run a node, [setting up your own LND node](https://shattered.io/bitcoin-lightning-node-lnd-setup-2026/) first will make the lab feel familiar, though the scripts below build everything from scratch.\n\nThe finished lab has two LND nodes (a seller called alice and a buyer called bob), a Bitcoin [Core 32.1](https://shattered.io/utxo-management-bitcoin-core-32-1-psbt-v2-12-steps-2026/) backend, a Python API on port 8000, Aperture on port 8081, and a client script that pays invoices on its own, caps what it will spend, and reuses tokens. Expect about 90 minutes if the downloads cooperate. Every step lists the output you should see, so you can stop at the first mismatch instead of debugging five layers at once.\n\nDon't miss new tech stories on Google\n\nAdd Shattered once in the Google app and our stories show up in your news suggestions.\n\n[Add now](https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F)\n\n## How an L402 Paywall Works on the Lightning Network\n\nL402 turns a Lightning payment into an API key. A client asks for a resource with no credentials, the server replies with HTTP 402 Payment Required, and the reply carries two things in a WWW-Authenticate header: a token and a Lightning invoice. The token commits to the invoice through its payment hash. The client pays, receives the payment preimage as proof, and retries the request with the token and the preimage together. Only that pair is valid, which means nobody can forge access without paying.\n\nThe official L402 documentation describes the token format as a macaroon, a bearer credential that can carry permissions and can be attenuated and delegated by whoever holds it. Verification needs only a root key and basic cryptography, so the server does not look up a payment record to authorize a call. Lightning Labs formalized the protocol as bLIP-0026 and said on March 11, 2026 that L402 was production-ready. The company also said Lightning Loop has used L402 through Aperture since its first release, roughly five years earlier.\n\nAperture is the piece that does the work. Lightning Labs describes it in its builder documentation as “a reverse proxy that acts as a payment and authentication gateway for Lightning Network powered APIs.” Your backend never touches Lightning. Aperture mints the macaroons, asks LND for invoices, checks credentials, and forwards good requests to your service. A [write-up of the pi402 launch](https://automater.ai/news/lightning-labs-pi402-bitcoin-inference-payments/) summarizes the same loop: an unpaid request gets a 402, an incomplete credential, and an invoice, and paying reveals the preimage that completes the credential.\n\n| Stage | Who acts | What happens | \n|---|---|---|\n| 1. Request | Client | Sends GET with no Authorization header | \n| 2. Challenge | Aperture | Returns 402 plus a WWW-Authenticate header holding a macaroon and a BOLT 11 invoice | \n| 3. Payment | Client wallet | Pays the invoice over Lightning and receives the preimage | \n| 4. Retry | Client | Sends Authorization: L402 with the macaroon and preimage joined by a colon | \n| 5. Proxy | Aperture | Validates the pair, then forwards the request to your backend | \n| 6. Reuse | Client | Keeps the token and skips payment on later calls to the same service | \n\nCompare that with a classic API key. There is no signup form, no billing portal, no card on file, and no chargeback. A script with a funded wallet can buy access to a service it has never seen, which is why Lightning Labs keeps pairing L402 with agent tooling. The same property makes the paywall useful for plain human developers who want to sell a data feed by the request without building an account system.\n\nOne naming wrinkle trips people up. Older posts and some Aperture log lines use LSAT, the protocol’s earlier name. Treat LSAT and L402 as the same thing in historical material, and write L402 in anything you build today. Header parsing in Step 8 accepts the current name only, which matches what Aperture v0.6.0 documents.\n\n## Prerequisites and Version Check\n\nPin versions before you type a command, because Lightning guides age fast. LND is still labeled beta, and its repository’s releases page listed v0.21.8-beta as the latest release as of November 14, 2026. Anything that tells you to run v0.21.6-beta or earlier predates the newer releases. For Bitcoin Core, check the version against its own release page instead of trusting a guide.\n\n| Component | Version for this lab | Date | Role | \n|---|---|---|---|\n| Bitcoin Core | 32.1 | Oct. 27, 2026 | Regtest chain backend for both LND nodes | \n| LND (lnd and lncli) | v0.21.8-beta | Nov. 11, 2026 | Lightning nodes for seller and buyer | \n| Aperture | v0.6.0 | Announced Nov. 12, 2026 | L402 reverse proxy in front of the API | \n| Go | Release current on your machine, docs floor is 1.19 | n/a | Builds Aperture from source | \n| Python | 3.10 or later | n/a | Backend server and the paying client | \n| curl and jq | Any recent release | n/a | Inspecting headers and JSON output | \n\nYou need a shell and free local ports for the backend, both Lightning nodes, and Aperture. If a port is taken, change it in the script and in the YAML in the same edit. Two ports collide most often: Aperture documents 8081 as its default, and many dev boxes already run something on 8080.\n\nInstall Bitcoin Core 32.1 from the official release page, and verify the checksum and signatures as the release notes describe. Do the same for the LND v0.21.8-beta tarball for your platform, then copy `lnd` and `lncli` onto your PATH. Check both with their version flags before moving on. If you want a longer walkthrough of a production-style node, the guide to [setting up a Lightning Network node](https://shattered.io/lightning-network-node-setup-2026/) covers Docker, backups, and monitoring in depth.\n\n```\nbitcoind --version | head -1\nlnd --version\nlncli --version\ngo version\npython3 --version\njq --version\n```\n\nExpected output looks like the lines below. Your commit hashes and build tags will differ slightly, and that is fine as long as the version numbers match.\n\n```\nBitcoin Core daemon version v32.1.0\nlnd version 0.21.8-beta commit=v0.21.8-beta\nlncli version 0.21.8-beta commit=v0.21.8-beta\ngo version go1.2x linux/amd64\nPython 3.12.x\njq-1.7\n```\n\nLab only: the scripts start LND with the no-seed-backup flag so nobody has to write down a mnemonic for coins that have no value. That flag stores the wallet unencrypted. Never copy it to a mainnet node. Step 12 lists what changes when real money is involved.\n\n## Build the Regtest Lab: Steps 1 to 4\n\nThe lab needs a chain, two funded nodes, and a channel between them with liquidity on the right side. The buyer opens the channel, which gives the seller inbound capacity. That detail matters later, because a seller with zero inbound liquidity can mint invoices all day and never get paid.\n\n### Step 1: Create the Lab Folder and Shell Helpers\n\nPut every path and helper function in one file so each later command stays short. Shell functions work in scripts, which aliases do not, so the helpers below are functions.\n\n```\nmkdir -p ~/l402-lab && cd ~/l402-lab\ncat > env.sh <<'EOF'\nexport LAB=\"$HOME/l402-lab\"\nexport PATH=\"$PATH:$(go env GOPATH)/bin\"\n\nbtc()   { bitcoin-cli -regtest -datadir=\"$LAB/bitcoin\" -rpcuser=lab -rpcpassword=labpass \"$@\"; }\nalice() { lncli --lnddir=\"$LAB/alice\" --rpcserver=localhost:10009 --network=regtest \"$@\"; }\nbob()   { lncli --lnddir=\"$LAB/bob\"   --rpcserver=localhost:10010 --network=regtest \"$@\"; }\nmine()  { btc -rpcwallet=miner generatetoaddress \"$1\" \"$(btc -rpcwallet=miner getnewaddress)\" > /dev/null; }\nEOF\nsource env.sh\ntype alice\n```\n\nThe last command should print `alice is a function` followed by its body. If your shell reports `not found`, the file did not load, so source it again from the lab directory.\n\n### Step 2: Start Bitcoin Core 32.1 in Regtest and Mine Coins\n\nLND talks to Bitcoin Core over RPC for blocks and over ZeroMQ for fast notifications, so both ZMQ endpoints must be set when bitcoind starts. The transaction index is optional for LND but harmless here. Creating a wallet named miner and mining 101 blocks matures the first coinbase reward, because regtest coinbase outputs need 100 confirmations before they can be spent.\n\n```\nsource env.sh\nmkdir -p \"$LAB/bitcoin\"\nbitcoind -regtest -daemon -datadir=\"$LAB/bitcoin\" -server=1 -txindex=1 \\\n  -rpcuser=lab -rpcpassword=labpass -fallbackfee=0.0002 \\\n  -zmqpubrawblock=tcp://127.0.0.1:28332 \\\n  -zmqpubrawtx=tcp://127.0.0.1:28333\nsleep 3\nbtc -named createwallet wallet_name=miner\nmine 101\nbtc -rpcwallet=miner getbalance\nbtc getblockcount\n{\n  \"name\": \"miner\"\n}\n50.00000000\n101\n```\n\n### Step 3: Start Two LND Nodes Named Alice and Bob\n\nAlice sells, Bob buys. Each node gets its own directory, peer port, gRPC port, and REST port. Keep 8081 free for Aperture, since that is its documented default, and give each node’s REST port a different value. For a deeper look at the flags a long-running node needs, the [Bitcoin Lightning node setup guide](https://shattered.io/bitcoin-lightning-node-setup-2026/) explains each one.\n\n```\nsource env.sh\nstart_lnd() {  # name p2p rpc rest\n  mkdir -p \"$LAB/$1\"\n  nohup lnd --lnddir=\"$LAB/$1\" --noseedbackup --alias=\"$1\" \\\n    --listen=127.0.0.1:$2 --rpclisten=127.0.0.1:$3 --restlisten=127.0.0.1:$4 \\\n    --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind \\\n    --bitcoind.rpchost=127.0.0.1:18443 \\\n    --bitcoind.rpcuser=lab --bitcoind.rpcpass=labpass \\\n    --bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332 \\\n    --bitcoind.zmqpubrawtx=tcp://127.0.0.1:28333 \\\n    > \"$LAB/$1.log\" 2>&1 &\n}\nstart_lnd alice 9735 10009 8080\nstart_lnd bob   9736 10010 8082\nsleep 10\nalice getinfo | jq '{alias, version, synced_to_chain}'\nbob   getinfo | jq '{alias, version, synced_to_chain}'\n{\n  \"alias\": \"alice\",\n  \"version\": \"0.21.8-beta commit=v0.21.8-beta\",\n  \"synced_to_chain\": true\n}\n{\n  \"alias\": \"bob\",\n  \"version\": \"0.21.8-beta commit=v0.21.8-beta\",\n  \"synced_to_chain\": true\n}\n```\n\nIf `getinfo` complains that the macaroon file does not exist, LND is still creating the wallet. Wait ten seconds and run it again. If `synced_to_chain` stays false for more than a minute, read the log at `$LAB/alice.log` and check the ZMQ ports first.\n\n### Step 4: Fund Bob and Open a Channel to Alice\n\nSend one regtest bitcoin to Bob’s on-chain wallet, confirm it, connect the peers, and open a channel of 1,000,000 sats. All of Bob’s balance starts on his side of the channel. Once the channel is active, Bob can pay Alice up to the channel’s capacity minus reserves, and Alice has the same amount as inbound liquidity. Channel liquidity is the main operating cost on mainnet too, and techniques such as [channel splicing](https://shattered.io/lightning-network-splicing-2026/) let you resize a channel without closing it.\n\n```\nsource env.sh\nBOB_ADDR=$(bob newaddress p2tr | jq -r .address)\nbtc -rpcwallet=miner sendtoaddress \"$BOB_ADDR\" 1\nmine 6\nbob walletbalance | jq -r .confirmed_balance\n\nALICE_PUB=$(alice getinfo | jq -r .identity_pubkey)\nbob connect \"[email protected]:9735\"\nbob openchannel --node_key=\"$ALICE_PUB\" --local_amt=1000000\nmine 6\nsleep 5\nbob listchannels | jq '.channels[] | {active, capacity, local_balance, remote_balance}'\n100000000\n{\n  \"funding_txid\": \"9c1e...e47a\",\n  \"output_index\": 0\n}\n{\n  \"active\": true,\n  \"capacity\": \"1000000\",\n  \"local_balance\": \"992950\",\n  \"remote_balance\": \"0\"\n}\n```\n\nYour `local_balance` will differ by a few thousand sats because the channel reserves part of the capacity for commitment fees. What matters is `active: true`. If it prints false, mine six more blocks and ask again. The lab now has everything a real Lightning seller and buyer would have, minus the real money.\n\n## Install and Configure Aperture: Steps 5 to 7\n\nWith the Lightning side running, the seller needs a gateway and something worth selling. Aperture builds from source with the Go toolchain, reads one YAML file, and talks to Alice’s LND over gRPC to create invoices.\n\n### Step 5: Build Aperture v0.6.0 From Source\n\nThe official install instructions are three commands: clone the repository, enter it, and run `make install`. Check out the v0.6.0 tag first so you build the release and not whatever sits on the main branch today. The docs list Go 1.19 or later as the floor, but read `go.mod` in the tag you check out and use a Go release at least that new.\n\n```\nsource env.sh\ngit clone https://github.com/lightninglabs/aperture.git \"$LAB/aperture-src\"\ncd \"$LAB/aperture-src\"\ngit checkout v0.6.0\nmake install\nwhich aperture\nHEAD is now at 3f9a2c1 Merge pull request ... from lightninglabs/v0.6.0\n/home/you/go/bin/aperture\n```\n\nThe commit hash in your output will not match the sample. If `which aperture` prints nothing, `$(go env GOPATH)/bin` is not on your PATH. The env file from Step 1 adds it, so source that file again in the same shell.\n\n### Step 6: Write aperture.yaml\n\nAperture looks for its configuration at `~/.aperture/aperture.yaml`. The structure below follows the sample in the official step-by-step guide: a listen address, an authenticator block that points at Alice’s LND, a database backend, and a list of services. Use absolute paths everywhere. YAML does not expand a tilde or `$HOME`, and a path that looks right but does not resolve is the most common reason Aperture fails on its first start. Replace `YOU` with your username.\n\n```\nmkdir -p ~/.aperture\ncat > ~/.aperture/aperture.yaml <<'EOF'\nlistenaddr: \"127.0.0.1:8081\"\ndebuglevel: \"debug\"\ndbbackend: \"sqlite\"\n\nauthenticator:\n  network: \"regtest\"\n  disable: false\n  lndhost: \"127.0.0.1:10009\"\n  tlspath: \"/home/YOU/l402-lab/alice/tls.cert\"\n  macdir: \"/home/YOU/l402-lab/alice/data/chain/bitcoin/regtest\"\n\nservices:\n  - name: \"labapi\"\n    hostregexp: '^localhost.*$'\n    pathregexp: '^/service.*$'\n    address: \"127.0.0.1:8000\"\n    protocol: http\n    timeout: 3600\n    price: 25\n    authwhitelistpaths:\n      - '^/service/free/.*$'\nEOF\n```\n\nEach key earns its place. The `authenticator` block tells Aperture which LND node mints the invoices, so Alice’s TLS certificate and macaroon directory must be real. The `hostregexp` matches the Host header, which is why the test URLs in this lab say `localhost` and not `127.0.0.1`. The `pathregexp` decides which paths belong to this service. The `address` is your backend. The `price` is in sats, and the official docs note that a valid token for a service unlocks unlimited resources on that service. The `authwhitelistpaths` list carves out a free tier.\n\n### Step 7: Start the Backend and Aperture, Then See the 402\n\nCreate a tiny backend with one paid file and one free file, bind it to localhost only, and start Aperture in a second terminal. Binding the backend to 127.0.0.1 matters more than it looks. If the backend listens on all interfaces, anyone who finds port 8000 walks around the paywall.\n\n```\n# terminal 1: the backend\nsource ~/l402-lab/env.sh\nmkdir -p \"$LAB/backend/service/free\" && cd \"$LAB/backend\"\necho '{\"pair\":\"BTCUSD\",\"note\":\"paid data, regtest\"}' > service/data.json\necho '{\"status\":\"free tier ok\"}' > service/free/status.json\npython3 -m http.server 8000 --bind 127.0.0.1\n\n# terminal 2: the gateway\nsource ~/l402-lab/env.sh\naperture\n```\n\nAperture generates a self-signed TLS key and certificate on first run, per its install guide, so use `https` and tell curl to accept the certificate with `-k`. Production deployments need a valid certificate for your domain. Now send an unauthenticated request in a third terminal.\n\n```\ncurl -sik https://localhost:8081/service/data.json\nHTTP/2 402\nwww-authenticate: L402 macaroon=\"AgEEbHNhdAJCAAAAAAAAAAAAAAAAAAAAAAAA...\", invoice=\"lnbcrt250n1pn4...x9qy\"\ncontent-type: text/plain; charset=utf-8\n\npayment required\n```\n\nThat reply is the whole product. The invoice prefix `lnbcrt250n` means regtest and 250 nanobitcoin, which equals 25 sats, exactly the `price` in the YAML. If you request `/service/free/status.json` instead, you should get a plain 200 with the free JSON body, because the whitelist pattern matches it.\n\n## Pay the Invoice and Fetch the Data: Steps 8 and 9\n\nA payment by hand teaches the protocol faster than any diagram. Do it once manually, then let a script do it every time after.\n\n### Step 8: Pay by Hand and Replay With the L402 Header\n\nPull the macaroon and invoice out of the header, pay the invoice from Bob, read the preimage from his payment history, and retry with `Authorization: L402 macaroon:preimage`. Two details deserve care. HTTP/2 lowercases header names, so match case-insensitively. Aperture v0.6.0 can advertise both L402 and MPP, which means the response may carry more than one WWW-Authenticate line, so filter for the one that starts with L402.\n\n```\nsource ~/l402-lab/env.sh\nHDR=$(curl -sik https://localhost:8081/service/data.json \\\n  | tr -d '\\r' | grep -i '^www-authenticate: L402')\nMAC=$(echo \"$HDR\" | sed -n 's/.*macaroon=\"\\([^\"]*\\)\".*/\\1/p')\nINV=$(echo \"$HDR\" | sed -n 's/.*invoice=\"\\([^\"]*\\)\".*/\\1/p')\n\nbob payinvoice --force \"$INV\"\nPRE=$(bob listpayments --reversed --max_payments=1 | jq -r '.payments[0].payment_preimage')\n\ncurl -sk -H \"Authorization: L402 $MAC:$PRE\" https://localhost:8081/service/data.json\n+------------+--------------+--------------+--------------+-----+----------+-------+--------+\n| HTLC_STATE | ATTEMPT_TIME | RESOLVE_TIME | RECEIVER_AMT | FEE | TIMELOCK | CHAN  | ROUTE  |\n+------------+--------------+--------------+--------------+-----+----------+-------+--------+\n| SUCCEEDED  | 0.139        | 0.402        | 25           | 0   | 1131     | ...   | ...    |\n+------------+--------------+--------------+--------------+-----+----------+-------+--------+\nAmount + fee:   25 + 0 sat\nPayment status: SUCCEEDED, preimage: 7d3a...c1f0\n\n{\"pair\":\"BTCUSD\",\"note\":\"paid data, regtest\"}\n```\n\nRepeat only the last curl and you get the data again with no second payment. The token already proves the preimage. Change a single character of the preimage and the request goes back to a 402, which confirms the gateway verifies the pair and not just the presence of a header.\n\n### Step 9: Write a Python Client That Pays Within a Budget\n\nA script that pays any invoice a server sends is a wallet-draining bug waiting to happen. The client below decodes the invoice first, refuses anything above a spending cap, pays through Bob, checks that the [SHA-256](https://shattered.io/category/cryptography/) of the preimage equals the invoice’s payment hash, and caches the token for later calls. It shells out to `lncli` so it needs no extra dependencies.\n\n``` python\n#!/usr/bin/env python3\nimport hashlib, json, os, re, ssl, subprocess, sys\nimport urllib.error, urllib.parse, urllib.request\n\nLAB = os.path.expanduser(\"~/l402-lab\")\nLNCLI = [\"lncli\", f\"--lnddir={LAB}/bob\", \"--rpcserver=localhost:10010\", \"--network=regtest\"]\nTOKENS = os.path.join(LAB, \"tokens.json\")\nMAX_SATS = int(os.environ.get(\"MAX_SATS\", \"100\"))\n\nCTX = ssl.create_default_context()\nCTX.check_hostname = False          # lab only: Aperture uses a self-signed cert\nCTX.verify_mode = ssl.CERT_NONE\n\ndef lncli(*args):\n    out = subprocess.run(LNCLI + list(args), capture_output=True, text=True, check=True).stdout\n    return json.loads(out) if out.lstrip().startswith((\"{\", \"[\")) else out\n\ndef fetch(url, token=None):\n    req = urllib.request.Request(url)\n    if token:\n        req.add_header(\"Authorization\", f\"L402 {token}\")\n    try:\n        with urllib.request.urlopen(req, context=CTX) as r:\n            return r.status, r.headers.get_all(\"WWW-Authenticate\") or [], r.read()\n    except urllib.error.HTTPError as e:\n        return e.code, e.headers.get_all(\"WWW-Authenticate\") or [], e.read()\n\ndef cache_key(url):\n    u = urllib.parse.urlparse(url)\n    first = u.path.strip(\"/\").split(\"/\")[0]\n    return f\"{u.netloc}/{first}\"\n\ndef load():\n    return json.load(open(TOKENS)) if os.path.exists(TOKENS) else {}\n\ndef pay(challenge):\n    mac = re.search(r'macaroon=\"([^\"]+)\"', challenge).group(1)\n    inv = re.search(r'invoice=\"([^\"]+)\"', challenge).group(1)\n    dec = lncli(\"decodepayreq\", inv)\n    sats = int(dec[\"num_satoshis\"])\n    if sats > MAX_SATS:\n        sys.exit(f\"refusing to pay {sats} sats, cap is {MAX_SATS}\")\n    lncli(\"payinvoice\", \"--force\", inv)\n    pre = lncli(\"listpayments\", \"--reversed\", \"--max_payments=1\")[\"payments\"][0][\"payment_preimage\"]\n    if hashlib.sha256(bytes.fromhex(pre)).hexdigest() != dec[\"payment_hash\"]:\n        sys.exit(\"preimage does not match the invoice payment hash\")\n    print(f\"paid {sats} sats\", file=sys.stderr)\n    return f\"{mac}:{pre}\"\n\ndef main(url):\n    tokens = load()\n    key = cache_key(url)\n    status, auth, body = fetch(url, tokens.get(key))\n    if status == 402:\n        challenge = next((h for h in auth if h.strip().upper().startswith(\"L402\")), None)\n        if not challenge:\n            sys.exit(\"402 without an L402 challenge\")\n        tokens[key] = pay(challenge)\n        json.dump(tokens, open(TOKENS, \"w\"))\n        status, auth, body = fetch(url, tokens[key])\n    if status != 200:\n        sys.exit(f\"unexpected status {status}\")\n    sys.stdout.write(body.decode())\n\nif __name__ == \"__main__\":\n    main(sys.argv[1])\n```\n\nSave it as `l402_client.py` in the lab folder. Run it twice against the paid path. The first run pays 25 sats and prints the JSON. The second run finds the cached token and prints the JSON with no payment line.\n\n```\npython3 l402_client.py https://localhost:8081/service/data.json\npython3 l402_client.py https://localhost:8081/service/data.json\npaid 25 sats\n{\"pair\":\"BTCUSD\",\"note\":\"paid data, regtest\"}\n{\"pair\":\"BTCUSD\",\"note\":\"paid data, regtest\"}\n```\n\nNow test the spending cap. Delete `tokens.json` and rerun the client with `MAX_SATS=10` in front of it. The script prints `refusing to pay 25 sats, cap is 10` and exits with a non-zero code before any payment leaves Bob’s node, which is the behavior you want from an unattended agent. The token cache is keyed by host and first path segment, so one payment covers every file under `/service`, matching the way Aperture treats a valid token as access to the whole service.\n\n## Pricing, Free Routes, and a Test Script: Steps 10 and 11\n\n### Step 10: Choose a Pricing Model\n\nA fixed price per service is the simplest model and the one the lab uses. Aperture offers more. The pricing guide documents a `dynamicprice` block that points at a gRPC price server, and Aperture v0.6.0 added opt-in metered pricing, where one payment buys a usage bundle that real traffic then draws down. A static price of 0 does not make an endpoint free. The v0.6.0 notes say it falls back to a default of 1 sat, and a genuinely free endpoint uses `auth: \"off\"` or a whitelist path.\n\n| Model | How you configure it | Best for | \n|---|---|---|\n| Fixed price | `price: 25` on the service | Flat-fee data files, one-off reports, first prototypes | \n| Free paths | `authwhitelistpaths` with regex patterns | Health checks, docs, a free sample to hook buyers | \n| Free service | `auth: \"off\"` on the service | Routes that must never return 402 | \n| Dynamic price | `dynamicprice` with`enabled` ,`grpcaddress` ,`insecure` ,`tlscertpath` | Per-resource prices computed by your own gRPC server | \n| Metered bundles | Opt-in metering with the `meterd` reference price server (v0.6.0) | Token-based AI inference and streamed responses | \n\nTo try a second price tier, add another entry under `services` with its own name, `pathregexp`, and `price`. Aperture matches a request to a service by host and path, so give each pattern a distinct prefix such as `/premium`. Restart Aperture after every YAML edit, because it reads the file once at startup.\n\n### Step 11: Automate the Check With a Test Script\n\nA paywall that silently stops charging is worse than one that is down. The script below asserts four behaviors: unpaid requests get a 402, the free path stays open, the first paid call costs exactly one payment, and the second call costs none. Run it after every config change.\n\n``` bash\n#!/usr/bin/env bash\nset -euo pipefail\nHERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nsource \"$HERE/env.sh\"\nBASE=https://localhost:8081\nfail() { echo \"FAIL: $*\"; exit 1; }\ncode() { curl -sk -o /dev/null -w '%{http_code}' \"$@\"; }\ncount() { bob listpayments | jq '.payments | length'; }\n\nrm -f \"$LAB/tokens.json\"\n\n[ \"$(code $BASE/service/data.json)\" = 402 ] || fail \"unpaid request should return 402\"\n[ \"$(code $BASE/service/free/status.json)\" = 200 ] || fail \"free path should return 200\"\n\nbefore=$(count)\npython3 \"$HERE/l402_client.py\" $BASE/service/data.json > /dev/null 2>&1\nmid=$(count)\n[ $((mid - before)) -eq 1 ] || fail \"first call should cost exactly one payment\"\n\npython3 \"$HERE/l402_client.py\" $BASE/service/data.json > /dev/null 2>&1\nafter=$(count)\n[ \"$after\" -eq \"$mid\" ] || fail \"second call should reuse the token\"\n\necho \"PASS: 402, free path, one payment, token reuse\"\nchmod +x test.sh && ./test.sh\nPASS: 402, free path, one payment, token reuse\n```\n\n## Harden the Seller for Mainnet: Step 12\n\n### Step 12: Replace Every Lab Shortcut\n\nThe lab made four shortcuts on purpose. Each one needs a real replacement before a customer pays you a satoshi. First, drop `--noseedbackup`, create the wallet with a seed and a password, and write the mnemonic down offline. Second, replace the self-signed certificate with a valid one for your domain and serve Aperture on 443. Third, move the macaroon directory out of reach. Aperture needs only enough authority to create and look up invoices, so point it at a baked macaroon with those permissions instead of the admin file. Fourth, firewall the backend so only Aperture can reach it.\n\nPatch discipline matters more on a node that holds channel funds. LND’s v0.21.6-beta release on October 30, 2026 disabled MPTCP listeners by default after an exploit path linked to CVE-2026-98432 was demonstrated. The release notes call that a mitigation and not a substitute for a Linux kernel carrying the upstream fix, so patch the kernel too. Earlier in the year, a Lightning Labs advisory dated August 11, 2026 described an [HTLC sweep failure tied to a wallet budget constraint](https://security.lightning.engineering/2026/08/11/lnd-htlc-sweep-budget-failure.html), fixed in v0.19.0-beta. Run the newest release, v0.21.8-beta at the time of writing, and treat any older number in a guide as a warning sign.\n\nA repository change requiring TLS 1.2 as Aperture’s minimum version was visible on GitHub in early November 2026, though it had not been confirmed as part of a release by November 15. Put Aperture behind a reverse proxy or load balancer that enforces modern TLS regardless. For a repeatable routine that checks your node against current advisories, the [Lightning Network upgrade audit](https://shattered.io/lightning-network-upgrade-audit-12-steps-2026/) turns patch checks into a script.\n\n- Create the mainnet wallet with a seed and password, and back up the channel state file after every channel change.\n- Use an Aperture-specific macaroon with invoice permissions only, never `admin.macaroon` .\n- Serve Aperture on 443 with a certificate for your domain, and keep the backend on a private interface.\n- Open a channel from your side only after you understand inbound liquidity, since buyers pay you through inbound capacity.\n- Set a price that clears routing fees. A 1-sat price can lose money on a long route.\n- Log every 402 and every paid request so you can reconcile invoices against traffic.\n\n## Advanced Tips: Metering, MPP, and Wavelength-Backed Sellers\n\nAperture v0.6.0 changes what a seller can do. Lightning Labs said it adds MPP support alongside L402, so a service can advertise both and let the buyer pick the protocol it speaks. It also adds real-time per-request accounting and an embedded `meterd` price server that quotes bundles and deducts token usage. A client buys credit once, each response debits it, and a new 402 arrives when the balance runs out. Fixed-price paywalls charge for access. Metered ones charge for consumption.\n\nThe biggest structural change is that a seller no longer needs LND. Per the release description, Aperture v0.6.0 can connect directly to a Wavelength wallet, so a seller can issue invoices without running a node, managing channels, or sourcing inbound liquidity. An [EL7 report on the launch](https://el7.ai/en/news/2026-11-13-lightning-labs-launches-bitcoin-payment-tools-for-autonomous-ai-agents) repeats that point. The setting is called `wavelengthgateway`, and you should read the v0.6.0 release notes for its exact syntax before using it, since this lab does not exercise it.\n\n| Factor | LND-backed seller (this lab) | Wavelength-backed seller | \n|---|---|---|\n| Runs an LND node | Yes | No, per the v0.6.0 release description | \n| Channels and inbound liquidity | You manage both | Not required | \n| Fees | Routing fees on each payment | 0.25% service fee for sending and receiving, plus ordinary routing fees | \n| Published limits | Your channel capacity | Accounts up to 300,000 sats, each VTXO up to 50,000 sats | \n| Status | Established path, works on any network | Mainnet launch announced Oct. 23, 2026 | \n\nThose Wavelength limits and fees come from the October 23, 2026 mainnet announcement, and a [CoinMarketCap Academy piece](https://coinmarketcap.com/academy/vi/article/lightning-labs-wavelength-ai-agents-bitcoin-mainnet) restates the 0.25% figure. Price your service with that fee in mind. A tiny per-request price on a small account can shrink faster than the headline percentage suggests once routing fees land on top.\n\nOn the buyer side, Lightning Labs’ Lightning Agent Tools, released February 11, 2026, include `lnget`, an L402-aware command-line client for paying gated APIs. For coding agents, pi402 ships as the `@lightninglabs/pi-402` package installed through the Pi harness. Your Python client in Step 9 is the same idea as a short script, and it works against any L402 server.\n\n## What a Request Costs: Sats, Millisats, and Token Math\n\nPrices in Aperture’s YAML are whole sats, but inference-style metering works in millisatoshis, and mixing the two causes the most expensive mistakes. One sat equals 1,000 msat. The pi402 announcement gave an example rate of 1 msat per input token and 2 msat per output token. Lightning Labs framed it as an illustration and not a published price, and it estimated roughly $0.80 per million input tokens and $1.61 per million output tokens at the exchange rate it cited.\n\n| Workload | Input tokens | Output tokens | Cost in msat | Cost in sats | \n|---|---|---|---|---|\n| Small prompt | 1,000 | 500 | 2,000 | 2 | \n| Medium chat turn | 10,000 | 2,000 | Not listed | Not listed | \n| Long document summary | 100,000 | 5,000 | Not listed | Not listed | \n| One million tokens each way | 1,000,000 | 1,000,000 | 3,000,000 | 3,000 | \n\nThe table applies the announcement’s example rates, so treat it as arithmetic and not a quote from any live service. It shows why metering matters. A flat 25-sat price would overcharge the small prompt by more than ten times and undercharge the long summary. Metered bundles let one payment cover many calls, which also cuts the number of Lightning payments you route. Fewer payments mean fewer chances to hit a failed route.\n\nFees and liquidity set the floor on what you can sell. Every payment needs a path with enough capacity, and a seller who only receives needs inbound liquidity from peers. Plan for it the way a shop plans for a card terminal. For the broader map of Bitcoin tools that sit around Lightning, from cold storage to mining, the [cryptocurrency section](https://shattered.io/category/cryptocurrency/) collects the related guides.\n\n## Common Pitfalls When Selling an API Over Lightning\n\nMost failures in a first L402 build come from a short list of mistakes. Each one below has burned someone who thought the setup was finished.\n\n- **Binding the backend to all interfaces.** If the Python server listens on 0.0.0.0, a visitor who finds port 8000 skips Aperture and reads your paid data for free. Bind to 127.0.0.1 or firewall the port.\n- **Using tilde or $HOME in the YAML.** Aperture reads paths literally. A`tlspath` of`~/l402-lab/alice/tls.cert` fails at startup with a file-not-found error that looks like an LND problem.\n- **Forgetting inbound liquidity.** A seller node with channels where all the balance sits on its own side can create invoices that nobody can pay. In the lab, Bob opens the channel to Alice for exactly this reason.\n- **Setting price to 0 and expecting free access.** The v0.6.0 notes say a static price of 0 defaults to 1 sat. Use`auth: \"off\"` or a whitelist path for free routes.\n- **Parsing only the first WWW-Authenticate header.** With L402 and MPP both advertised, the first line may not be the one your client understands. Select the line that starts with L402.\n- **Paying without a cap.** A client that pays any invoice a server returns will drain its wallet on a hostile or buggy endpoint. Decode first, compare to a limit, and verify the preimage hash.\n- **Confusing sats and msat.** Aperture prices are in sats. Inference metering examples use millisatoshis. A factor of 1,000 hides in that gap.\n- **Copying version numbers from old guides.** A tutorial that pins LND v0.20.x or Aperture before v0.6.0 misses the MPTCP mitigation, metering, and the Wavelength option.\n- **Shipping the no-seed-backup flag.** It exists for regtest. On mainnet it leaves an unencrypted wallet on disk with no recovery phrase.\n\nNotice how many of these are configuration slips and not protocol bugs. The L402 handshake itself is small. The surrounding plumbing, from ports to paths to liquidity, is where the hours go.\n\n## Troubleshooting: 12 Failures and Fixes\n\nWork from the bottom of the stack up. Confirm Bitcoin Core, then each LND node, then the channel, then Aperture, then the client. The table maps common symptoms to the layer that usually causes them.\n\n| Symptom | Likely cause | Fix | \n|---|---|---|\n| `make install` fails with a Go version error | Go older than the repo requires | Read `go.mod` in the v0.6.0 tag and install a Go release at least that new | \n| `aperture: command not found` | GOPATH bin not on PATH | Run `source env.sh` or add`$(go env GOPATH)/bin` to PATH | \n| Aperture exits at startup citing the certificate or macaroon | Wrong `tlspath` or`macdir` , or a tilde in the path | Use absolute paths to alice/tls.cert and the regtest data directory | \n| LND stays at `synced_to_chain: false` | ZMQ ports missing on bitcoind, or bitcoind not running | Restart bitcoind with both `-zmqpub` flags and check the LND log | \n| `getinfo` says the macaroon file does not exist | LND is still creating the wallet | Wait ten seconds, then retry | \n| curl reports an SSL certificate problem | Aperture serves a self-signed certificate | Add `-k` in the lab, install a valid certificate in production | \n| Every request returns 404 or bypasses the paywall | `hostregexp` does not match the Host header, such as 127.0.0.1 vs localhost | Use the hostname your regex expects, or widen the pattern | \n| Bad gateway or connection refused after payment | Backend not running on port 8000 | Start the Python server and curl it directly on 127.0.0.1:8000 | \n| `payinvoice` fails with no route or insufficient balance | Channel not active, or Bob has no outbound funds | Mine six blocks, confirm `active: true` , check`local_balance` | \n| 402 persists after paying | Wrong preimage, or a header that is not `L402 macaroon:preimage` | Pull the preimage with `listpayments --reversed` and check the colon and spacing | \n| Cached token suddenly rejected | Aperture database deleted or service renamed | Delete `tokens.json` and pay again, since tokens tie to the gateway that minted them | \n| Port already in use on 8080 or 8081 | Another dev service holds the port | Change the port in start script and YAML together, then restart both | \n\nWhen nothing above matches, raise the log level. Aperture already runs with `debuglevel: \"debug\"` in the lab, and LND writes to `$LAB/alice.log`. Read the first error and ignore the cascade after it. A failed invoice creation on Alice’s node usually shows up in Aperture’s log within the same second as the request.\n\n## Complete Working Project: One Script to Build the Whole Lab\n\nThe pieces so far fit together as one folder. Keep the files below in `~/l402-lab`, and the lab rebuilds from nothing in a few minutes.\n\n```\nl402-lab/\n  env.sh               # Step 1: paths and helper functions\n  start-lab.sh         # Steps 2-4: chain, two nodes, funded channel\n  l402_client.py       # Step 9: budgeted L402 client\n  test.sh              # Step 11: four assertions\n  stop-lab.sh          # tears everything down\n~/.aperture/aperture.yaml   # Step 6: gateway config\n```\n\nThe start script below repeats Steps 2 to 4 with waits in place of sleeps, so it is safe to rerun from a clean folder. It also writes the two backend files from Step 7. Start the backend and Aperture yourself afterwards, in their own terminals.\n\n``` bash\n#!/usr/bin/env bash\nset -euo pipefail\nHERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nsource \"$HERE/env.sh\"\nmkdir -p \"$LAB\"/{bitcoin,alice,bob,backend/service/free}\n\nbitcoind -regtest -daemon -datadir=\"$LAB/bitcoin\" -server=1 -txindex=1 \\\n  -rpcuser=lab -rpcpassword=labpass -fallbackfee=0.0002 \\\n  -zmqpubrawblock=tcp://127.0.0.1:28332 -zmqpubrawtx=tcp://127.0.0.1:28333\nuntil btc getblockchaininfo > /dev/null 2>&1; do sleep 1; done\nbtc -named createwallet wallet_name=miner > /dev/null 2>&1 || btc loadwallet miner > /dev/null\nmine 101\n\nstart_lnd() {\n  nohup lnd --lnddir=\"$LAB/$1\" --noseedbackup --alias=\"$1\" \\\n    --listen=127.0.0.1:$2 --rpclisten=127.0.0.1:$3 --restlisten=127.0.0.1:$4 \\\n    --bitcoin.active --bitcoin.regtest --bitcoin.node=bitcoind \\\n    --bitcoind.rpchost=127.0.0.1:18443 --bitcoind.rpcuser=lab --bitcoind.rpcpass=labpass \\\n    --bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332 \\\n    --bitcoind.zmqpubrawtx=tcp://127.0.0.1:28333 > \"$LAB/$1.log\" 2>&1 &\n}\nstart_lnd alice 9735 10009 8080\nstart_lnd bob   9736 10010 8082\n\nsynced() { [ \"$($1 getinfo 2>/dev/null | jq -r .synced_to_chain)\" = \"true\" ]; }\nuntil synced alice; do sleep 2; done\nuntil synced bob;   do sleep 2; done\n\nbtc -rpcwallet=miner sendtoaddress \"$(bob newaddress p2tr | jq -r .address)\" 1 > /dev/null\nmine 6\nuntil [ \"$(bob walletbalance | jq -r .confirmed_balance)\" -gt 0 ]; do sleep 1; done\n\nALICE_PUB=$(alice getinfo | jq -r .identity_pubkey)\nbob connect \"[email protected]:9735\" > /dev/null\nbob openchannel --node_key=\"$ALICE_PUB\" --local_amt=1000000 > /dev/null\nmine 6\nuntil [ \"$(bob listchannels | jq '[.channels[] | select(.active)] | length')\" = \"1\" ]; do sleep 2; done\n\necho '{\"pair\":\"BTCUSD\",\"note\":\"paid data, regtest\"}' > \"$LAB/backend/service/data.json\"\necho '{\"status\":\"free tier ok\"}' > \"$LAB/backend/service/free/status.json\"\necho \"lab ready: alice sells, bob buys, channel active\"\nbash\n#!/usr/bin/env bash\nsource \"$(dirname \"$0\")/env.sh\"\nalice stop 2>/dev/null || true\nbob stop 2>/dev/null || true\nbtc stop 2>/dev/null || true\npkill -f \"http.server 8000\" || true\npkill -x aperture || true\n```\n\nRun order for a fresh session: `./start-lab.sh`, then the backend in terminal 1, `aperture` in terminal 2, and `./test.sh` in terminal 3. A passing run ends with the single PASS line from Step 11. To start over, run `./stop-lab.sh`, delete `~/l402-lab/bitcoin`, `alice`, and `bob`, remove `tokens.json`, and run the start script again.\n\n## Frequently Asked Questions\n\n**What is an L402 paywall?** It is an API gate that charges per access with a Lightning payment. The server answers an unpaid request with HTTP 402, a macaroon token, and an invoice. After the client pays, the preimage plus the token acts as the credential for later requests.\n\n**Is L402 the same as LSAT?** L402 is the current name for the protocol that earlier material called LSAT. Lightning Labs describes L402 as production-ready as of March 11, 2026 and identifies it as bLIP-0026.\n\n**Do I still need an LND node to sell with Aperture?** Not always. Aperture v0.6.0 can create invoices through a Wavelength wallet, so a seller can skip running a node, managing channels, and sourcing inbound liquidity. An LND-backed setup, like the one in this lab, remains the path when you want to run your own infrastructure.\n\n**Which LND and Bitcoin Core versions should I run?** At the time of writing, the LND repository lists v0.21.8-beta as the latest release as of November 14, 2026, and it is still labeled beta. For Bitcoin Core, use the current release listed on its official release page.\n\n**Can I test everything without real bitcoin?** Yes. Regtest gives you a private chain where you mine your own blocks, and this lab uses it for every step. Moving to mainnet means a real seed, real channels, real inbound liquidity, a valid TLS certificate, and an Aperture-specific macaroon.\n\n**How much can I charge per request?** Aperture prices are set in sats, and the lab uses 25. Anything below the routing cost can lose money, so test paths to your node before you pick a price. Metered pricing in v0.6.0 uses finer units and bills actual usage, which suits token-based services.\n\n**What does the client need to pay an L402 invoice?** Any wallet that can pay a BOLT 11 invoice and expose the preimage. The lab uses lncli through a Python script. Lightning Labs also released lnget in its Agent Tools on February 11, 2026, as an L402-aware command-line client.", "url": "https://wpnews.pro/news/lightning-network-l402-paywall-12-steps-90-min-2026", "canonical_source": "https://shattered.io/l402-paywall-lightning-network-aperture-12-steps-2026/", "published_at": "2026-11-15 16:27:17+00:00", "updated_at": "2026-11-15 16:49:27.807304+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "ai-tools"], "entities": ["Lightning Labs", "Aperture v0.6.0", "pi402", "L402", "bLIP-0026", "Lightning Network", "LND", "Bitcoin Core 32.1"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/lightning-network-l402-paywall-12-steps-90-min-2026", "markdown": "https://wpnews.pro/news/lightning-network-l402-paywall-12-steps-90-min-2026.md", "text": "https://wpnews.pro/news/lightning-network-l402-paywall-12-steps-90-min-2026.txt", "jsonld": "https://wpnews.pro/news/lightning-network-l402-paywall-12-steps-90-min-2026.jsonld"}}