A single route serves the whole LNURL-pay flow:
GET|OPTIONS /{username?}. The amount query parameter decides which step you get.
| Request | Step | Response |
|---|---|---|
GET /{username} |
LNURL-pay request | pay params (tag: payRequest) |
GET /{username}?amount={msat} |
LNURL-pay callback | bolt11 invoice under pr |
OPTIONS /{username} |
CORS preflight | empty body, CORS headers |
username is optional — without it the configured receiver is used. amount is in
millisatoshis; the backend is billed in sats (amount / 1000).
Every response carries Access-Control-Allow-Origin: * (CorsMiddleware), so
browser-based wallets can call the endpoint directly.
curl 'https://example.com/bob'{
"callback": "https://example.com",
"maxSendable": 10000000000,
"minSendable": 100000,
"metadata": "[[\"text/plain\",\"Pay to bob@example.com\"],[\"text/identifier\",\"bob@example.com\"]]",
"tag": "payRequest",
"commentAllowed": false
}callbackissetCallbackUrl()verbatim — wallets append?amount=.metadatais built byLnurlPayMetadatawithjson_encode, so quotes in your description or address cannot break the JSON.commentAllowedis alwaysfalse(LUD-12 comments are not implemented).
curl 'https://example.com/bob?amount=2000'{
"pr": "lnbc20n1p...",
"status": "OK",
"memo": "",
"successAction": { "tag": "message", "message": "Payment received!" },
"routes": [],
"disposable": false,
"error": null
}The bolt11 invoice is under pr, as LUD-06 requires. The backend receives the amount in
sats plus description_hash (sha256 of the metadata) and unhashed_description.
Errors come back as the LNURL error object. Uncaught exceptions are converted by
InvoiceExceptionHandler, registered globally in InvoiceRoutesPlugin, so no controller
needs a try/catch.
{ "status": "ERROR", "reason": "Amount is not between minimum and maximum sendable amount" }{ "status": "ERROR", "reason": "Missing backend options for carol" }An unreachable backend answers with status: ERROR and
error: Backend "LnBits" unreachable.
Wallets resolve bob@example.com through:
https://example.com/.well-known/lnurlp/bob
Route that path to /{username}. With nginx:
location /.well-known/lnurlp/ {
rewrite ^/\.well-known/lnurlp/(.*)$ /$1 last;
}
location / {
try_files $uri /index.php$is_args$args;
}Requirements for interoperability: HTTPS, JSON responses, and a callback URL reachable
from the public internet.
The facade is usable directly, which is what the feature tests do:
use Gacela\Framework\Gacela;
use PhpLightning\Invoice\InvoiceFacade;
Gacela::bootstrap(__DIR__);
$facade = new InvoiceFacade();
$payParams = $facade->getCallbackUrl('bob');
$invoice = $facade->generateInvoice('bob', 2_000); // millisats