A self-hosted LNURL server for a Lightning node: payment requests, withdraw requests and passwordless login, backed by your own LND or Core Lightning node for the actual invoice/payment plumbing. Run it just for yourself, or host it for friends and family - either way, no third-party custodian, just your own node and infrastructure.
LNURLserver lets each registered user manage their own set of LNURL links from a web UI, and serves the corresponding LNURL/LN Address endpoints publicly. It implements:
| LUD | What it gives you |
|---|---|
| 01 | Base bech32 LNURL encoding/decoding |
| 02 | channelRequest - convert a user's balance into one incoming channel |
| 03 | withdrawRequest - hand out QR codes any wallet can withdraw from |
| 04 | lnurl-auth - passwordless login/registration via a linking key |
| 05 | Seed-phrase-derived auth keys, no external wallet required |
| 06 | payRequest - accept payments against a link |
| 07 | hostedChannelRequest - advertise this instance's node for a hosted channel |
| 08 | Fast withdrawRequest - discovery fields embedded directly in the link |
| 09 / 10 | Success actions after payment (message, URL, AES payload) |
| 11 | disposable - per-link toggle for whether a payRequest is one-shot |
| 12 | Payer comments on a payRequest |
| 13 | signMessage-derived auth keys, for hardware/node-signed logins |
| 14 | balanceCheck - a reusable withdrawRequest for the default link |
| 15 | balanceNotify - accepts and stores a wallet's notify-me-later URL |
| 16 | Lightning Address (user@domain) for every payment link |
| 17 | Raw lnurlp:/lnurlw:/keyauth: URL schemes alongside bech32 |
| 18 | Payer identity (name/email/pubkey/identifier/auth) in payRequest |
| 19 | payLink - a user's payRequest, discoverable from their withdrawRequest |
| 20 | Long payment description for a payRequest |
| 21 | verify - check whether a payRequest invoice was actually paid |
| 23 | addressRequest - let a wallet push a receive address to a user |
A user can hold several payment (lnurlp) and withdraw (lnurlw) links at
once - one of each is the account's default, tied to its username. Every
user's links move funds through this instance's own node - see
Configuration - configured once by the operator, not
per-user.
Each link holds its own spendable balance: a lnurlp accrues balance as its
payRequest invoices get paid, and a lnurlw spends down its own balance on
withdrawal - unless it's linked to a payLink (LUD-19), in which case it
charges that payLink's balance directly instead. Balance can also be moved
explicitly from a lnurlp to a lnurlw (in full or in part). The /u/wallet
page lists every invoice received and payment sent, filterable per link,
updates live over a websocket whenever a balance changes anywhere on the
account, and embeds a "paste an LNURL, Lightning Address, or raw BOLT11
invoice" widget to pay or withdraw right there and watch the resulting
balance change land.
backend/- a FastAPI app (lnurl_serverpackage) that serves the public LNURL endpoints, a cookie-session-authenticated/uAPI for managing a user's links and settings, and talks to this instance's own LND/CLN node over HTTP for invoice creation/payouts. Storage is SQLite via SQLAlchemy, schema-versioned with Alembic.frontend/- a SolidJS + TypeScript SPA (Vite build) that logs in via lnurl-auth and drives the/uAPI to manage payment/withdraw links, Lightning Address, account settings, and the wallet view. The API client is generated from the backend's OpenAPI schema; aGET /u/balance/wswebsocket pushes a refresh signal to every logged-in tab whenever a balance moves.
Requires uv and Node.js.
make install # uv sync + apply Alembic migrations
make dev # backend, http://localhost:8000
make frontend # frontend dev server, http://localhost:3000See the Makefile for the full set of targets - make lint runs ruff,
pyright, mypy and tsc; make check runs the black/ruff/prettier formatting
checks. See Testing for make test and everything else.
Backend runtime settings are read from environment variables (via
pydantic-settings),
optionally loaded from a .env file. Copy backend/.env.template to
backend/.env and fill in whatever you need - every setting is optional.
| Variable | Purpose |
|---|---|
FUNDINGSOURCE_BACKEND |
lnd or cln - this instance's own node, used for every user's lnurlp/lnurlw invoices and for LUD-02 channelRequest (node identity, connect/balance-check/open-channel). Unset disables all of that. |
FUNDINGSOURCE_URL |
REST base URL of that node (lnd's REST API, or cln's clnrest plugin). |
FUNDINGSOURCE_MACAROON |
lnd only: hex-encoded macaroon. Needs offchain/onchain/address/peers/info permissions (an admin.macaroon-level scope) to fully automate channelRequest; getinfo alone is enough for just node identity + regular payments. |
FUNDINGSOURCE_RUNE |
cln only: a rune (from lightning-cli createrune) with equivalent permissions. |
FUNDINGSOURCE_CERT_PATH |
Optional: path to a self-signed TLS cert to verify the funding source against - both backends commonly self-sign. Leave unset if it's fronted by a reverse proxy with a real certificate. |
Don't have a node handy to test against? See Testing below -
e2e/docker/ spins up a local bitcoind + lnd + cln regtest
environment with a channel already opened between them.
make test # everything: backend + frontend + e2e
make test-backend # pytest
make test-frontend # vitestbackend/tests/- unit/integration tests. The funding source is mocked (httpx.MockTransport) except intest_regtest.py, which runs the same lnd/cln calls for real and self-skips unlessREGTEST_LND_URLis set (see "End-to-end and regtest" below) - it never accidentally runs outside CI or a real regtest environment.frontend/src/**/*.test.tsx- component tests (Vitest +@solidjs/testing-library).e2e/- its own package, independent offrontend/. Drives a real browser (Playwright) against the actual built app with no mocked funding source at all - see below.
e2e/docker/ spins up a local Bitcoin regtest environment (bitcoind +
lnd + cln, with a channel opened between them) plus two real,
containerized instances of this app - one backed by lnd, one by cln - so
e2e tests (and manual testing, below) exercise real invoices, real
payments, and real LUD-02 channel opens instead of a mocked funding
source. Full details (including first-time browser cert setup for
https://s1.dev/https://s2.dev) are in e2e/docker/README.md.
sudo apt install jq # if not already installed
make run-test-fresh # spins up regtest + both app instances
make test-e2e # runs the Playwright suite against them
make run-test-stop # stop the app instances (leaves regtest running)
make regtest-stop # tear down regtest too (chain/wallet state and all)make run-test-fresh (first time) / make run-test (after, to pick up code
changes) already gives you two fully configured, fully functional instances
of the real app - no separate dev servers or hand-written backend/.env
needed:
make run-test-fresh # from repo root - see "End-to-end and regtest" above- lnd-backed instance: https://s1.dev
- cln-backed instance: https://s2.dev
(Direct, no TLS: http://localhost:8010 / http://localhost:8020 - but
https://s1.dev/https://s2.dev match production more closely, including
the cross-instance forward_lnaddress/"fetch on server" paths, which rely
on those hostnames actually resolving. First time through, there's a
one-time step to trust Caddy's local cert - see "First-time browser setup"
in e2e/docker/README.md.)
Register an account on each (seed-phrase login, no external wallet needed), create a payment/withdraw link, and pay/withdraw against one from the other instance - each is backed by a different real node with a real channel open between them.
make build # docker build, bundles the frontend into the backend image
make run # runs it on :8000, with the SQLite file persisted in ./datamake run automatically passes --env-file backend/.env if that file
exists (see Configuration).
MIT, see backend/pyproject.toml.