Menu
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

BenefitDescription
ReusableOne address for unlimited payments
Dynamic amountsPayer chooses amount
MetadataRich payment information
InteroperabilityWide wallet support

LNURL Types

TypePurposeFlow
lnurl-payReceive paymentsHTTP → Invoice
lnurl-withdrawClaim fundsHTTP → Payment
lnurl-authAuthenticationHTTP → Signature
lnurl-channelRequest channelHTTP → 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:

FieldTypeDescription
tagstringMust be “payRequest”
callbackstringURL to request invoice
minSendableintegerMin amount in millisatoshis
maxSendableintegerMax amount in millisatoshis
metadatastringJSON array of [mime, content]
commentAllowedintegerMax 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

TagDescription
messageDisplay a message
urlRedirect to URL
aesDecrypt 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

ActionPurpose
loginStandard login
registerNew account
linkLink wallet to existing account
authGeneric 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

ErrorMeaning
”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

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}"
  }
}