How to check that a Lightning address actually works
How to check that a Lightning address actually works
A Lightning address looks like an email address: name@domain. The common name for this scheme is LUD-16. Wallets use it to fetch a Lightning invoice without asking the sender to paste a long lnbc… string.
Before you put an address on a profile, or pay one, it is worth checking three things: that the public lookup returns real pay-request metadata, that the amount limits make sense, and that an invoice comes back for the amount you asked for. If you want the address to receive Nostr zaps, you should also look at allowsNostr and nostrPubkey.
This guide is a manual check you can run with curl or a short Python script. Nothing in the examples is paid. The invoice is only decoded.
1. Resolve the address (the .well-known/lnurlp lookup)
For alice@example.com, the lookup URL is:
https://example.com/.well-known/lnurlp/alice
Use HTTPS. If the local part has characters outside [a-zA-Z0-9-_.], URL-encode it. A healthy response is JSON with Content-Type: application/json, not an HTML page.
If the JSON contains "status": "ERROR", stop and read reason. A typical reply for an unknown user is:
{"status": "ERROR", "reason": "Recipient unavailable."}
2. Fields to check in the pay-request metadata
A valid LNURLp payload (LUD-06) should include:
| Field | What to check |
|---|---|
tag |
Must be payRequest. |
callback |
The URL you will request an invoice from. Prefer https://. |
minSendable |
Lowest amount the endpoint will invoice, in millisatoshis. 1000 msats = 1 sat. |
maxSendable |
Highest amount, also in millisatoshis. maxSendable must be greater than or equal to minSendable. |
commentAllowed |
Optional. Maximum comment length in characters. If the field is missing, the provider may ignore comments or reject them — try only if you need comments. |
allowsNostr |
Optional, but needed for NIP-57 zaps. Should be true. |
nostrPubkey |
Optional, but needed for zaps. A 64-character hex key. This is the key that will sign zap receipts (kind 9735). On a hosted Lightning address it is often the service key, not your Nostr identity. That is normal. On a self-hosted endpoint it should be the key you expect. |
You may also see metadata (a JSON string used in invoice construction) and, later, a verify URL on the invoice response (LUD-21). This guide does not poll verify; it only checks that an invoice is issued for the right amount.
3. Request an invoice and check the amount
Take callback from the lookup. If it has no query string, append ?amount=<millisatoshis>. If it already has ?…, append &amount=<millisatoshis>.
A success body usually looks like {"pr":"lnbc…","routes":[]}. Some providers add extra fields. Failures are not always in the LNURL shape. You may get the standard {"status":"ERROR","reason":"…"}, or an HTTP 400 with a provider-specific body such as {"error":true,"message":"Invalid amount"}.
Then decode the amount inside the bolt11 string in pr. The amount lives in the human-readable prefix, before the 1 that starts the bech32 data:
lnbc1u…= 1 microbitcoin = 100 satslnbc21u…= 2,100 satslnbc10n…= 10 nanobitcoin = 1 sat- no multiplier (
lnbc1…followed immediately by the bech32 separator in a 1-BTC invoice) is 100,000,000 sats
The decoded amount must match what you requested. If it does not, treat the address as unsafe and do not pay. Leave test invoices unpaid if you are only checking the endpoint.
4. A small snippet we actually ran
The Python below does the lookup, prints the fields above, asks the callback for an invoice of minSendable, and checks the bolt11 amount. It uses only the standard library.
Default target: a real public Lightning address, npub1j57250rfv94vtc0yryg4c8zs2paasvg24nr840chnfdn9mguzsvqq9y5w5@npub.cash. Pass another name@domain as the first argument to check a different address.
#!/usr/bin/env python3
"""Check that a Lightning address (LUD-16) resolves and issues an invoice
for the amount you asked for. Nothing is paid. Standard library only."""
import json, re, sys, urllib.parse, urllib.request
def bolt11_sats(inv):
m = re.match(r"^lnbc(\d+)([munp]?)1", inv.lower())
if not m:
raise ValueError("cannot read amount from invoice")
n, unit = int(m.group(1)), m.group(2)
# 1 BTC = 100_000_000 sats; m/u/n/p are metric prefixes of BTC
return int(n * {"": 10**8, "m": 10**5, "u": 100, "n": 0.1, "p": 0.0001}[unit])
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": "ln-address-check-guide"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())
def fmt_sats(msats):
s = int(msats or 0) // 1000
return f"{s} sat" if s == 1 else f"{s} sats"
addr = sys.argv[1] if len(sys.argv) > 1 else "npub1j57250rfv94vtc0yryg4c8zs2paasvg24nr840chnfdn9mguzsvqq9y5w5@npub.cash"
name, domain = addr.split("@")
url = f"https://{domain}/.well-known/lnurlp/{urllib.parse.quote(name)}"
info = get(url)
if info.get("status") == "ERROR":
raise SystemExit("lookup failed: " + str(info.get("reason")))
print("tag:", info.get("tag"))
print("callback:", info.get("callback"))
print("minSendable:", info.get("minSendable"), "msats (" + fmt_sats(info.get("minSendable")) + ")")
print("maxSendable:", info.get("maxSendable"), "msats (" + fmt_sats(info.get("maxSendable")) + ")")
print("commentAllowed:", info.get("commentAllowed"))
print("allowsNostr:", info.get("allowsNostr"))
pk = info.get("nostrPubkey") or ""
print("nostrPubkey:", (pk[:12] + "..." + pk[-8:] + " (len=%d)" % len(pk)) if pk else None)
amount = int(info["minSendable"])
cb = info["callback"]
sep = "&" if "?" in cb else "?"
inv = get(f"{cb}{sep}amount={amount}")
pr = inv.get("pr", "")
got = bolt11_sats(pr) if isinstance(pr, str) and pr.startswith("ln") else None
want = amount // 1000
print("invoice:", "ok" if got == want else "MISMATCH or missing",
"(requested %s, invoice %s)" % (fmt_sats(want * 1000), fmt_sats((got or 0) * 1000) if got is not None else None))
Equivalent curl for the same address (lookup, then a 1-sat invoice, because this provider’s minSendable is 1000 msats). The callback on this service happens to be the same URL as the lookup; always use the callback field from the JSON rather than assuming that.
curl -sS "https://npub.cash/.well-known/lnurlp/npub1j57250rfv94vtc0yryg4c8zs2paasvg24nr840chnfdn9mguzsvqq9y5w5"
curl -sS "https://npub.cash/.well-known/lnurlp/npub1j57250rfv94vtc0yryg4c8zs2paasvg24nr840chnfdn9mguzsvqq9y5w5?amount=1000"
What we got (2026-11-11, Asia/Seoul)
The Python snippet printed:
tag: payRequest
callback: https://npub.cash/.well-known/lnurlp/npub1j57250rfv94vtc0yryg4c8zs2paasvg24nr840chnfdn9mguzsvqq9y5w5
minSendable: 1000 msats (1 sat)
maxSendable: 100000000 msats (100000 sats)
commentAllowed: None
allowsNostr: True
nostrPubkey: 8576fd19bfe6...1c021046 (len=64)
invoice: ok (requested 1 sat, invoice 1 sat)
A few extra checks against the same endpoint, not printed by the snippet:
- A 21-sat invoice also decoded to 21 sats.
amount=500(below the minimum) andamount=200000000(above the maximum, 200,000 sats) both returned HTTP 400 with{"error":true,"message":"Invalid amount"}.- A made-up local part on the same domain returned HTTP 200 and
{"status":"ERROR","reason":"Recipient unavailable."}. commentAllowedwas absent; sending a shortcommentwith a valid amount still returned an invoice. Do not assume every provider will do that.nostrPubkeyis 64 hex characters and is the provider’s key, not the Nostr profile that owns the address. That is expected for a hosted Lightning address.
Your numbers and error shapes will differ on other domains.
5. Common failure causes
- Typos in the name or domain, or a domain whose TLS certificate does not verify.
- The well-known URL returns HTML or HTTP 404: the host is not serving LNURLp.
- JSON is missing
callback, ortagis notpayRequest. - The requested amount is outside
minSendable…maxSendable. - The invoice amount does not match the request — treat this as a hard failure, even if a
pris present. - You need zaps, but
allowsNostris missing or false, ornostrPubkeyis missing or not 64 hex characters. - The callback is plain
http://(mixed content in a browser, or easy to intercept). - A comment longer than
commentAllowed, or a comment sent to a provider that rejects them. - The local part is case-sensitive on some hosts; use the address exactly as published.
- Some endpoints are picky about
User-Agentor rate limits. The snippet sends a short User-Agent for that reason.
A small script, if you would rather not copy the snippet
The same kind of check — print the metadata, optionally request a test invoice, pay nothing — is also available as a one-file starter, LN Address Check, for 210 sats:
https://plebeian.market/products/a3039ca1365c43d4af1eba042492f88f72042216c21376ab6656bf637c03368c
It is one of four small Python tools in the Nostr Bot Toolkit, 1,000 sats (a DVM demand scan, an inbox/zap counter, this Lightning-address checker, and a NIP-99 listing builder):
https://plebeian.market/products/0019f85fb0c77f2bd99859e0b6005713906ba478018de2a58ca29924267bbf61
The guide above is complete on its own. You do not need either product to run the check.
Written by team botkora with AI assistance. The snippet and the extra amount checks were run against the public Lightning address above on 2026-11-11 (Asia/Seoul). No invoices were paid. Other providers will look different.
Write a comment