Registration Flow
The flagship guide: onboard a visitor from nothing to a paid lightning address bound to their npub.
This is the flow the SDK was built around: a domain owner's webapp turns a
visitor with (or without) a Nostr identity into username@your-domain — with
the operator's registration price paid over lightning in the middle.
The instance decides the policy, the same client code handles both:
- Free registration —
addresses.create()succeeds directly. - Paid registration — the create call answers 402. The app then mints a lightning invoice, shows its QR, and once the payment settles proves it with the preimage; the server creates the address in the same transaction.
How the paid path works
POST /api/wallet/addresses→402 Payment Required.POST /api/invoiceswith the desired username →{ id, bolt11, verify, expiresAt, amountSats }.- The user pays
bolt11from any wallet (QR scan or WebLN). - The app polls the LUD-21
verifyURL until it answers{ settled: true, preimage }. POST /api/invoices/[id]/claimwith the preimage —sha256(preimage) === paymentHashis the proof of payment — and the address is created atomically for the authenticated npub.
No webhooks, no operator work in the middle, and the preimage never requires trusting the payer's wallet: the LUD-21 verifier hands it out only after settlement.
One call: claimAddress
The SDK orchestrates the whole thing:
import { LaWalletClient, generateSigner } from '@lawallet/sdk'
const { signer, nsec } = generateSigner() // or the user's own signer
const wallet = new LaWalletClient({
endpoint: 'https://wallet.example.com',
signer
})
const result = await wallet.registration.claimAddress({
username: 'satoshi',
onInvoice: invoice => {
renderQr(invoice.bolt11) // called only when the instance charges
console.log(`Pay ${invoice.amountSats} sats`)
}
})
console.log(result) // { lightningAddress: 'satoshi@wallet.example.com', paid: true }claimAddress resolves when the address exists — immediately on the free
path, after settlement on the paid one. It polls for the invoice's full
lifetime, treats an already-claimed invoice as success (a lost response must
not strand a paid user), and surfaces a half-configured paid mode as a typed
LaWalletError with code PAID_REGISTRATION_INCOMPLETE. Pass an
AbortSignal to cancel.
In React: useClaimAddress
The hook version powers a full UI — it is a direct port of the claim screen in LaWallet's own wallet, driven by the SDK:
import { useClaimAddress } from '@lawallet/sdk/react'
import { QRCodeSVG } from 'qrcode.react'
function ClaimScreen() {
const flow = useClaimAddress({
onCreated: address => confetti(address)
})
if (flow.step === 'payment' && flow.invoice) {
return (
<section>
<h2>
Pay {flow.invoice.amountSats} sats for {flow.username}@{flow.domain}
</h2>
<QRCodeSVG value={flow.invoice.bolt11.toUpperCase()} size={240} />
{flow.hasWebLn && (
<button
onClick={flow.handleWebLnPay}
disabled={flow.payingWithWallet}
>
Pay with extension
</button>
)}
<button onClick={flow.handleManualCheck} disabled={flow.manualChecking}>
I paid — check now
</button>
<button onClick={flow.backFromPayment}>Cancel</button>
{flow.paymentStatus === 'expired' && (
<p>Invoice expired — start over.</p>
)}
</section>
)
}
if (flow.step === 'success') {
return <h2>⚡ {flow.claimedAddress}</h2>
}
return (
<form onSubmit={flow.handleSubmit}>
<input
value={flow.username}
onChange={e => flow.setUsername(e.target.value.toLowerCase())}
/>
<small>
{flow.formatError ??
(flow.checking ? 'Checking…' : flow.available ? 'Available ✓' : '')}
</small>
<button disabled={flow.submitDisabled}>
{flow.submitting ? 'Claiming…' : 'Claim'}
</button>
{flow.error && <p role="alert">{flow.error}</p>}
</form>
)
}What the hook handles for you:
- Availability — debounced format + availability checking while typing.
- The 402 branch — mints the invoice and switches to the payment step.
- Settlement — LUD-21 polling for the invoice's whole lifetime, plus a manual "I paid — check now" escape hatch and a WebLN one-click path (the extension returns the preimage directly).
- Refresh survival — the pending invoice is stashed in
sessionStorage; a reload lands back on the QR with polling resumed, so a user who already paid is never stranded. - Race safety — the poller, the manual check and WebLN can't double-claim.
- Cache invalidation — on success,
useUser/useAddressesrefetch by themselves.
The complete app
example-onboarding
wires this guide end to end — branded landing, nostr login (extension /
generated key / nsec), this claim flow, then alias or NWC configuration —
in a few hundred lines of Vite + React.
Operator setup
Paid registration is configured on the instance (Settings → registration):
enable lightning registration, set the operator's receiving lightning address
and the price in sats. The receiving address must support LUD-21 verify —
the invoice endpoint refuses to mint otherwise, and claimAddress surfaces
that as PAID_REGISTRATION_INCOMPLETE rather than looping.