LNURL Protocol Integration
Lightning Network solved Bitcoin payment speed problem, but created new one: user needs copy invoice from seller's wallet into own wallet — this inconvenient. LNURL — set of protocols on top of Lightning allowing wallet to automatically request invoice via HTTP by scanning QR code once. From UX perspective approaches regular payment systems.
LNURL Protocol Family
LNURL — not single protocol, but several specs (LUD — Lightning URL Definitions). Each solves specific task:
| LUD | Protocol | Purpose |
|---|---|---|
| LUD-01 | LNURL-pay | Payment: wallet requests invoice from seller's server |
| LUD-03 | LNURL-withdraw | Withdrawal: wallet receives funds by link |
| LUD-04 | LNURL-auth | Authentication via Lightning key (passwordless login) |
| LUD-06 | LNURL-channel | Channel opening |
| LUD-12 | Lightning Address | Format [email protected] for LNURL-pay |
For accepting payments important LNURL-pay and Lightning Address.
How LNURL-pay Works
Entire process — two HTTP requests between wallet and server:
Step 1. User scans QR. Wallet sees lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hhgarjv4ehcmn9wsh8xmmrd9skcnjv... (bech32 encoded HTTPS URL). Wallet decodes and GET to this URL.
Step 2. Server returns metadata:
{
"tag": "payRequest",
"callback": "https://merchant.com/lnurl/pay/invoice",
"minSendable": 1000,
"maxSendable": 100000000,
"metadata": "[[\"text/plain\",\"Payment to My Shop\"],[\"image/png;base64\",\"iVBORw0...\"]]"
}
Step 3. User enters amount. Wallet GET callback with amount parameter (in millisatoshi):
GET https://merchant.com/lnurl/pay/invoice?amount=10000
Step 4. Server generates Lightning invoice via own LN node and returns:
{
"pr": "lnbc100n1pj...",
"routes": [],
"successAction": {
"tag": "message",
"message": "Payment confirmed! Order #12345"
}
}
Step 5. Wallet pays invoice. After successful payment shows successAction.
Implementing LNURL-pay Server
Need Lightning node (LND or Core Lightning) for invoice generation. Example on Node.js with LND via gRPC:
import express from 'express';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { bech32 } from 'bech32';
const app = express();
// LNURL-pay step 1: metadata
app.get('/lnurl/pay/:paymentId', async (req, res) => {
const { paymentId } = req.params;
const callbackUrl = `https://${req.hostname}/lnurl/pay/${paymentId}/invoice`;
// Encode URL in lnurl bech32 (for QR code)
const lnurlEncoded = encodeLnurl(callbackUrl);
res.json({
tag: 'payRequest',
callback: callbackUrl,
minSendable: 1000, // 1 sat in millisatoshi
maxSendable: 100_000_000, // 0.001 BTC
metadata: JSON.stringify([
['text/plain', `Payment for order ${paymentId}`],
]),
});
});
// LNURL-pay step 2: invoice generation
app.get('/lnurl/pay/:paymentId/invoice', async (req, res) => {
const { paymentId } = req.params;
const amountMsat = parseInt(req.query.amount as string);
if (!amountMsat || amountMsat < 1000) {
return res.status(400).json({ status: 'ERROR', reason: 'Invalid amount' });
}
try {
const invoice = await lndClient.addInvoice({
value_msat: amountMsat,
memo: `Order ${paymentId}`,
expiry: 3600, // 1 hour
});
// Save in DB: link invoice to paymentId
await db.saveInvoice({
paymentHash: invoice.r_hash,
paymentId,
amountMsat,
});
res.json({
pr: invoice.payment_request,
routes: [],
successAction: {
tag: 'message',
message: `Order ${paymentId} confirmed!`,
},
});
} catch (err) {
res.status(500).json({ status: 'ERROR', reason: 'Failed to generate invoice' });
}
});
function encodeLnurl(url: string): string {
const words = bech32.toWords(Buffer.from(url, 'utf8'));
return bech32.encode('lnurl', words, 1023).toUpperCase();
}
Lightning Address: [email protected]
Lightning Address (LUD-12) — most convenient UX. Instead of QR code user enters address like email. Wallet automatically requests https://domain.com/.well-known/lnurlp/username.
// Route for Lightning Address
app.get('/.well-known/lnurlp/:username', async (req, res) => {
const { username } = req.params;
const user = await db.getUserByLnAddress(username);
if (!user) {
return res.status(404).json({ status: 'ERROR', reason: 'User not found' });
}
res.json({
tag: 'payRequest',
callback: `https://${req.hostname}/lnurl/lightning-address/${username}`,
minSendable: 1000,
maxSendable: 10_000_000_000,
metadata: JSON.stringify([
['text/identifier', `${username}@${req.hostname}`],
['text/plain', `Payment to ${username}`],
]),
commentAllowed: 144, // support comments up to 144 characters
});
});
After this [email protected] works as Lightning Address in any compatible wallet (Phoenix, Wallet of Satoshi, Zeus, Breez).
LNURL-auth: Passwordless Login
LNURL-auth allows users to login via Lightning wallet without password. Wallet signs challenge with private key derived from Lightning seed.
import crypto from 'crypto';
// Generate challenge for login
app.get('/auth/lnurl', (req, res) => {
const k1 = crypto.randomBytes(32).toString('hex');
// Save k1 in Redis with TTL 5 minutes
redis.setex(`lnurl_auth:${k1}`, 300, 'pending');
const lnurlAuthUrl = `https://${req.hostname}/auth/callback?tag=login&k1=${k1}`;
const encoded = encodeLnurl(lnurlAuthUrl);
res.json({ lnurl: encoded, k1 });
});
// Callback from wallet (signature of k1)
app.get('/auth/callback', async (req, res) => {
const { k1, sig, key } = req.query as Record<string, string>;
const status = await redis.get(`lnurl_auth:${k1}`);
if (!status) {
return res.json({ status: 'ERROR', reason: 'Unknown k1' });
}
// Verify ECDSA signature (secp256k1)
const isValid = verifyLnurlAuthSignature(k1, sig, key);
if (!isValid) {
return res.json({ status: 'ERROR', reason: 'Invalid signature' });
}
// Mark k1 as authenticated with public key
await redis.setex(`lnurl_auth:${k1}`, 300, `authenticated:${key}`);
res.json({ status: 'OK' });
});
Frontend polls k1 status — once wallet signs, user logged in.
Infrastructure Requirements
Lightning node — mandatory. Options: LND (Go, most widespread, gRPC API), Core Lightning / CLN (C, UNIX socket + REST), Eclair (Scala, used by Acinq/Phoenix). For production: dedicated VPS with 4GB+ RAM, SSD, stable internet. Node must have inbound liquidity for accepting payments.
Hosted solutions for quick start: Voltage.cloud (managed LND), Alby Hub (self-custody but simplified), Strike API (custodial, but no own node needed). For serious use with high volumes — only own node.
TLS and domain mandatory: LNURL requires HTTPS. Self-signed certificate won't pass — need Let's Encrypt or similar.
Monitoring: channel balance (alert when < 10% inbound liquidity), invoice expiry, failed payment attempts. LND Metrics exports Prometheus-compatible metrics out of box.







