Fetching invoices from a Lightning Address
Getting BOLT11 Invoices from Lightning Addresses and LNURL
Introduction
Lightning Addresses provide a simple, email-like addressing system for Lightning Network payments. This guide shows how to retrieve BOLT11 invoices from either a Lightning Address (using the LUD-16 specification) or an LNURL string.
Lightning Addresses are built with the LNURL spec by implementing three features of the spec: LUD-01 (base LNURL encoding/decoding), LUD-06 (payRequest base spec), and LUD-16 (paying to static internet identifiers). Some Lightning Address providers also implement LUD-21 for verification directly with the Lightning Address host.
The spec implementation is only required for the Lightning Address host. A wallet or service only has to adhere to the LNURL scheme of requesting from the correct URLs in order to support fetching invoices.
Lightning Address Format
A Lightning Address follows the format: [email protected]
An LNURL string starts with LNURL followed by a bech32-encoded URL.
Step-by-Step Process
1. Parse the Lightning Address
def parse_lightning_address(lightning_address: str) -> tuple[str, str]:
"""
Split a Lightning Address into (username, domain).
Raises
------
ValueError
If the string is not in the expected format.
"""
try:
username, domain = lightning_address.split("@", maxsplit=1)
except ValueError:
raise ValueError("Invalid Lightning Address format") from None
if not username or not domain:
raise ValueError("Invalid Lightning Address format")
return username, domain2. Request a BOLT11 Invoice
This function handles both Lightning Addresses and LNURL strings:
import requests
from urllib.parse import urlencode
def request_bolt11_invoice(target: str, amount_sats: int, memo: str = "") -> str:
"""
Obtain a BOLT11 invoice for the requested amount (in sats).
Parameters
----------
target : str
Lightning Address (`name@domain`) or bech32 LNURL string.
amount_sats : int
memo : str, optional
Optional comment to attach to the invoice.
Returns
-------
str
The BOLT11 invoice string.
Raises
------
RuntimeError
For any network or protocol-level failure.
"""
if "@" in target: # treat as Lightning Address
username, domain = parse_lightning_address(target)
lnurlp_url = f"https://{domain}/.well-known/lnurlp/{username}"
lnurlp_data = _get_json(lnurlp_url)
if lnurlp_data.get("tag") != "payRequest":
raise RuntimeError("Invalid LNURL-pay response")
_enforce_amount_limits(lnurlp_data, amount_sats)
callback_url = _build_callback_url(
lnurlp_data["callback"], amount_sats, memo
)
invoice_data = _get_json(callback_url)
return _extract_invoice(invoice_data)
# otherwise assume a raw LNURL
return handle_lnurl_string(target, amount_sats, memo)
# utility helpers
def _get_json(url: str, *, timeout: int = 10) -> dict:
resp = requests.get(url, timeout=timeout)
if not resp.ok:
raise RuntimeError(f"HTTP {resp.status_code} from {url}")
return resp.json()
def _enforce_amount_limits(meta: dict, sats: int) -> None:
min_sat = meta["minSendable"] // 1000
max_sat = meta["maxSendable"] // 1000
if not (min_sat <= sats <= max_sat):
raise RuntimeError(
f"Amount must be between {min_sat} and {max_sat} sats"
)
def _build_callback_url(base: str, sats: int, memo: str) -> str:
params = {"amount": sats * 1000}
if memo:
params["comment"] = memo
connector = "&" if "?" in base else "?"
return f"{base}{connector}{urlencode(params)}"
def _extract_invoice(data: dict) -> str:
pr = data.get("pr")
if not pr:
raise RuntimeError("No BOLT11 invoice in response")
return pr
3. Handle LNURL Strings
from bech32 import bech32_decode, convertbits
def handle_lnurl_string(lnurl: str, amount_sats: int, memo: str = "") -> str:
if not lnurl.upper().startswith("LNURL"):
raise RuntimeError("Not a valid LNURL")
decoded_url = decode_bech32_url(lnurl)
lnurl_meta = _get_json(decoded_url)
if lnurl_meta.get("tag") != "payRequest":
raise RuntimeError("Not a valid LNURL-pay response")
_enforce_amount_limits(lnurl_meta, amount_sats)
callback_url = _build_callback_url(
lnurl_meta["callback"], amount_sats, memo
)
invoice_data = _get_json(callback_url)
return _extract_invoice(invoice_data)
# utility helpers
def _get_json(url: str, *, timeout: int = 10) -> dict:
resp = requests.get(url, timeout=timeout)
if not resp.ok:
raise RuntimeError(f"HTTP {resp.status_code} from {url}")
return resp.json()
def _enforce_amount_limits(meta: dict, sats: int) -> None:
min_sat = meta["minSendable"] // 1000
max_sat = meta["maxSendable"] // 1000
if not (min_sat <= sats <= max_sat):
raise RuntimeError(
f"Amount must be between {min_sat} and {max_sat} sats"
)
def _build_callback_url(base: str, sats: int, memo: str) -> str:
params = {"amount": sats * 1000}
if memo:
params["comment"] = memo
connector = "&" if "?" in base else "?"
return f"{base}{connector}{urlencode(params)}"
def _extract_invoice(data: dict) -> str:
pr = data.get("pr")
if not pr:
raise RuntimeError("No BOLT11 invoice in response")
return pr4. Decode Bech32 LNURL
def decode_bech32_url(lnurl: str) -> str:
"""
Convert bech32-encoded LNURL -> clear-text URL.
"""
hrp, data = bech32_decode(lnurl)
if hrp is None or hrp.lower() != "lnurl":
raise ValueError("Invalid LNURL string")
# Convert 5-bit groups back into raw bytes
decoded_bytes = bytes(convertbits(data, 5, 8, False))
return decoded_bytes.decode()Error Handling Tips
- Always validate the input format before processing (Lightning Address or LNURL)
- Check for minimum and maximum sendable amounts
- Handle network errors gracefully
- Implement timeouts for fetch requests
- Verify that the response contains a valid BOLT11 invoice
Additional Notes
- LUD-16 is an extension of the LNURL-pay protocol (LUD-06)
- LUD-01 defines the base LNURL encoding and decoding process
- Some Lightning Address providers may have rate limits
- The BOLT11 invoice will have an expiry time, typically 24 hours
- Consider implementing payment verification using the provider's API or LUD-21 (if available)
- When constructing callback URLs, be careful about query parameters that might already exist