How to check that a Lightning address actually works

A practical LUD-16 check: look up .well-known/lnurlp, inspect callback, amount limits, commentAllowed, and allowsNostr/nostrPubkey, request an invoice, and verify the amount. Includes a Python snippet tested on a real public Lightning address.

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 sats
  • lnbc21u… = 2,100 sats
  • lnbc10n… = 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) and amount=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."}.
  • commentAllowed was absent; sending a short comment with a valid amount still returned an invoice. Do not assume every provider will do that.
  • nostrPubkey is 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, or tag is not payRequest.
  • The requested amount is outside minSendable…maxSendable.
  • The invoice amount does not match the request — treat this as a hard failure, even if a pr is present.
  • You need zaps, but allowsNostr is missing or false, or nostrPubkey is 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-Agent or 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