Lightning Intermediate 7 min read
LNURL Protocol
LNURL protocol reference for Lightning payments. LNURL-pay, LNURL-withdraw, Lightning Address, and agent integration.
lnurl lightning-address lnurl-pay lnurl-withdraw lnurl-auth
LNURL Protocol
LNURL is an HTTP-based protocol that simplifies Lightning interactions. Instead of sharing long BOLT11 invoices, users share short URLs or Lightning Addresses.
Why LNURL Matters for Agents
| Benefit | Description |
|---|---|
| Reusable | One address for unlimited payments |
| Dynamic amounts | Payer chooses amount |
| Metadata | Rich payment information |
| Interoperability | Wide wallet support |
LNURL Types
| Type | Purpose | Flow |
|---|---|---|
lnurl-pay | Receive payments | HTTP → Invoice |
lnurl-withdraw | Claim funds | HTTP → Payment |
lnurl-auth | Authentication | HTTP → Signature |
lnurl-channel | Request channel | HTTP → Channel |
LNURL Encoding
LNURLs are bech32-encoded URLs:
Original: https://example.com/lnurl?q=123
Encoded: lnurl1dp68gurn8ghj7...
Encoding/Decoding
import bech32
def encode_lnurl(url: str) -> str:
"""Encode URL to LNURL format."""
data = url.encode('utf-8')
converted = bech32.convertbits(data, 8, 5)
return bech32.bech32_encode('lnurl', converted).upper()
def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to original URL."""
hrp, data = bech32.bech32_decode(lnurl.lower())
converted = bech32.convertbits(data, 5, 8, False)
return bytes(converted).decode('utf-8')
LNURL-Pay
Flow
1. Payer scans LNURL
2. Wallet fetches metadata from URL
3. User enters amount
4. Wallet requests invoice with amount
5. Server returns BOLT11 invoice
6. Wallet pays invoice
Server Response (Step 2)
GET https://example.com/lnurl-pay
Response:
{
"tag": "payRequest",
"callback": "https://example.com/lnurl-pay/callback",
"minSendable": 1000,
"maxSendable": 1000000000,
"metadata": "[[\"text/plain\",\"Payment to Example\"]]",
"commentAllowed": 140
}
Fields:
| Field | Type | Description |
|---|---|---|
tag | string | Must be “payRequest” |
callback | string | URL to request invoice |
minSendable | integer | Min amount in millisatoshis |
maxSendable | integer | Max amount in millisatoshis |
metadata | string | JSON array of [mime, content] |
commentAllowed | integer | Max comment length (optional) |
Callback Request (Step 4)
GET https://example.com/lnurl-pay/callback?amount=10000&comment=Thanks
Response:
{
"pr": "lnbc100n1...",
"routes": [],
"successAction": {
"tag": "message",
"message": "Payment received!"
}
}
Success Actions
| Tag | Description |
|---|---|
message | Display a message |
url | Redirect to URL |
aes | Decrypt content with preimage |
Agent Implementation
import requests
import hashlib
class LNURLPayClient:
def pay_lnurl(self, lnurl: str, amount_msat: int, comment: str = ""):
# Decode LNURL
url = decode_lnurl(lnurl)
# Fetch pay request info
info = requests.get(url).json()
if info['tag'] != 'payRequest':
raise ValueError("Not a pay request")
# Validate amount
if amount_msat < info['minSendable']:
raise ValueError("Amount too low")
if amount_msat > info['maxSendable']:
raise ValueError("Amount too high")
# Request invoice
params = {'amount': amount_msat}
if comment and info.get('commentAllowed', 0) > 0:
params['comment'] = comment[:info['commentAllowed']]
callback_response = requests.get(info['callback'], params=params).json()
# Return invoice to pay
return {
'invoice': callback_response['pr'],
'success_action': callback_response.get('successAction')
}
Lightning Address
Lightning Address is LNURL-pay with email-like format:
user@domain.com
Resolution
Lightning Address: alice@example.com
↓
Resolve to: https://example.com/.well-known/lnurlp/alice
↓
Standard LNURL-pay flow
Server Setup
URL: https://example.com/.well-known/lnurlp/{username}
Response: (same as LNURL-pay)
{
"tag": "payRequest",
"callback": "https://example.com/lnurlp/alice/callback",
...
}
Agent Implementation
def resolve_lightning_address(address: str) -> str:
"""Convert Lightning Address to LNURL-pay endpoint."""
username, domain = address.split('@')
return f"https://{domain}/.well-known/lnurlp/{username}"
def pay_lightning_address(address: str, amount_sats: int):
url = resolve_lightning_address(address)
info = requests.get(url).json()
# Request invoice
callback = requests.get(
info['callback'],
params={'amount': amount_sats * 1000} # Convert to msat
).json()
return callback['pr']
LNURL-Withdraw
Allows users to claim funds from a service.
Flow
1. Service provides LNURL-withdraw
2. User scans/enters LNURL
3. Wallet fetches withdraw info
4. User confirms withdrawal
5. Wallet sends their invoice to service
6. Service pays the invoice
Server Response
{
"tag": "withdrawRequest",
"callback": "https://example.com/lnurl-withdraw/callback",
"k1": "unique-identifier",
"defaultDescription": "Withdrawal from Example",
"minWithdrawable": 1000,
"maxWithdrawable": 100000000
}
Callback
GET https://example.com/lnurl-withdraw/callback?k1=...&pr=lnbc...
Response:
{
"status": "OK"
}
Agent Withdraw Implementation
def process_withdraw(lnurl: str, my_invoice: str):
"""Claim funds from LNURL-withdraw."""
url = decode_lnurl(lnurl)
info = requests.get(url).json()
if info['tag'] != 'withdrawRequest':
raise ValueError("Not a withdraw request")
# Submit our invoice
response = requests.get(
info['callback'],
params={
'k1': info['k1'],
'pr': my_invoice
}
).json()
return response['status'] == 'OK'
LNURL-Auth
Passwordless authentication using Lightning signatures.
Flow
1. Service shows LNURL-auth QR
2. Wallet derives key from domain
3. Wallet signs challenge
4. Service verifies signature
5. User authenticated
Server Challenge
{
"tag": "login",
"k1": "hex-encoded-32-byte-challenge",
"action": "login"
}
Actions
| Action | Purpose |
|---|---|
login | Standard login |
register | New account |
link | Link wallet to existing account |
auth | Generic authentication |
Verification
from secp256k1 import PublicKey
def verify_lnurl_auth(k1: str, sig: str, key: str) -> bool:
"""Verify LNURL-auth signature."""
challenge = bytes.fromhex(k1)
signature = bytes.fromhex(sig)
pubkey = PublicKey(bytes.fromhex(key), raw=True)
return pubkey.schnorr_verify(challenge, signature, None, raw=True)
LNURL in Invoices
BOLT11 invoices can embed LNURL metadata:
# Creating invoice with LNURL metadata
metadata = json.dumps([
["text/plain", "Payment to Agent"],
["text/identifier", "agent@example.com"]
])
# Hash for invoice
description_hash = hashlib.sha256(metadata.encode()).digest()
Error Handling
LNURL errors use this format:
{
"status": "ERROR",
"reason": "Description of the error"
}
Common Errors
| Error | Meaning |
|---|---|
| ”Amount out of range” | Below min or above max |
| ”Invoice expired” | k1 or session timed out |
| ”Already claimed” | Withdraw already used |
| ”Invalid signature” | Auth failed |
Security Considerations
For LNURL-Pay Servers
- Validate metadata hash in paid invoice
- Rate limit requests
- Use HTTPS only
For LNURL-Withdraw
- Single-use k1 values
- Short expiry times
- Monitor for abuse
For Agents
def validate_lnurl_response(response: dict, expected_tag: str):
"""Validate LNURL response."""
if response.get('status') == 'ERROR':
raise ValueError(response.get('reason', 'Unknown error'))
if response.get('tag') != expected_tag:
raise ValueError(f"Expected {expected_tag}, got {response.get('tag')}")
return True
Related Topics
- Invoices - BOLT11 format
- API: LNbits - LNbits LNURL extensions
- Wallets - LNURL-compatible wallets
Machine-Readable Summary
{
"protocol": "lnurl",
"types": [
{
"tag": "payRequest",
"purpose": "receive-payments",
"flow": "http-to-invoice"
},
{
"tag": "withdrawRequest",
"purpose": "claim-funds",
"flow": "http-to-payment"
},
{
"tag": "login",
"purpose": "authentication",
"flow": "http-to-signature"
}
],
"lightning_address": {
"format": "user@domain.com",
"resolution": "https://{domain}/.well-known/lnurlp/{user}"
}
}