Skip to content

LNURL Protocol

LNURL utilities implementing the Lightning URL protocol for Lightning Network payments and withdrawals. Covers LUD-01 (bech32 encode/decode), LUD-03 (withdraw requests), LUD-06 (pay requests), LUD-09 (success actions), LUD-10 (AES encrypted success actions), LUD-12 (comments), LUD-17 (scheme prefixes), LUD-18 (payer identity), LUD-20 (long description metadata), and LUD-21 (payment verification).

Import

ts
import { lnurl } from 'nostr-core'
// or import individual functions
import {
  encodeLnurl,
  decodeLnurl,
  isLnurl,
  resolveUrl,
  fetchPayRequest,
  requestInvoice,
  fetchWithdrawRequest,
  submitWithdrawRequest,
  parseSuccessAction,
  decryptAesSuccessAction,
  parseLnurlMetadata,
  verifyPayment,
  validateVerifyUrl,
} from 'nostr-core'

LnurlError Class

ts
class LnurlError extends Error {
  code: string
  constructor(message: string, code?: string)
}

All LNURL functions throw LnurlError on failure. The code field defaults to 'LNURL_ERROR'.

Types

SuccessAction

ts
type SuccessAction =
  | { tag: 'message'; message: string }
  | { tag: 'url'; description: string; url: string }
  | { tag: 'aes'; description: string; ciphertext: string; iv: string }
VariantDescription
messageLUD-09: Display a plaintext message to the user
urlLUD-09: Display a description and link
aesLUD-10: AES-encrypted payload, decrypted with the payment preimage

PayerDataSpec

ts
type PayerDataSpec = {
  name?: { mandatory: boolean }
  pubkey?: { mandatory: boolean }
  identifier?: { mandatory: boolean }
  email?: { mandatory: boolean }
  auth?: { mandatory: boolean; k1: string }
}

LUD-18: Describes which payer identity fields the service requires or accepts.

PayerData

ts
type PayerData = {
  name?: string
  pubkey?: string
  identifier?: string
  email?: string
  auth?: { key: string; k1: string; sig: string }
}

LUD-18: Payer identity data sent by the wallet to the service.

LnurlMetadata

ts
type LnurlMetadata = {
  plainText: string
  longDesc?: string
  image?: { type: string; data: string }
  entries: [string, string][]
}
FieldTypeDescription
plainTextstringShort description from text/plain entry
longDescstring (optional)LUD-20: Long description from text/long-desc entry
imageobject (optional)First image entry with its MIME type and base64 data
entries[string, string][]Raw array of all [mime-type, content] tuples

PayRequestResponse

ts
type PayRequestResponse = {
  tag: 'payRequest'
  callback: string
  minSendable: number
  maxSendable: number
  metadata: string
  commentAllowed?: number
  payerData?: PayerDataSpec
  allowsNostr?: boolean
  nostrPubkey?: string
}

LUD-06: First response from a pay request LNURL endpoint.

PayRequestCallbackResponse

ts
type PayRequestCallbackResponse = {
  pr: string
  routes: unknown[]
  successAction?: SuccessAction
  verify?: string
}
FieldTypeDescription
prstringBOLT-11 payment request (invoice)
routesunknown[]Optional route hints
successActionSuccessAction (optional)LUD-09: Action to perform after successful payment
verifystring (optional)LUD-21: URL to poll for payment verification

WithdrawRequestResponse

ts
type WithdrawRequestResponse = {
  tag: 'withdrawRequest'
  callback: string
  k1: string
  defaultDescription: string
  minWithdrawable: number
  maxWithdrawable: number
  pinLimit?: number          // LUD-24 / Bolt Card
  [key: string]: unknown     // any other fields the service returned
}

LUD-03: First response from a withdraw request LNURL endpoint.

FieldTypeDescription
pinLimitnumber?LUD-24: msat threshold above which the service requires the card PIN

Fields outside the typed shape are passed through rather than dropped, so service-specific extensions (e.g. balanceCheck) stay reachable without re-fetching the params.

VerifyResponse

ts
type VerifyResponse = {
  settled: boolean
  preimage: string | null
  pr: string
  [key: string]: unknown     // any other fields the service returned
}

Unknown fields are passed through. Fiat-payout providers extend the verify response with a delivery object (mpesa, payout: delivered, receipt, recipient, amount), which clients can surface without a second request.

LUD-21: Response from a payment verification endpoint.

RequestInvoiceOptions

ts
type RequestInvoiceOptions = {
  comment?: string
  payerData?: PayerData
  nostr?: string
}
FieldTypeDescription
commentstring (optional)LUD-12: Comment to attach to the payment
payerDataPayerData (optional)LUD-18: Payer identity data
nostrstring (optional)Stringified nostr zap request event

lnurl.encodeLnurl

ts
function encodeLnurl(url: string): string

Encodes a URL as a bech32 LNURL string (LUD-01).

ts
const encoded = lnurl.encodeLnurl('https://service.com/api?q=3fc3645b')
// 'lnurl1dp68gurn8ghj7...'

lnurl.decodeLnurl

ts
function decodeLnurl(encoded: string): string

Decodes a bech32 LNURL string back to the original URL (LUD-01). Throws LnurlError if the prefix is not lnurl.

ts
const url = lnurl.decodeLnurl('lnurl1dp68gurn8ghj7...')
// 'https://service.com/api?q=3fc3645b'

lnurl.isLnurl

ts
function isLnurl(str: string): boolean

Checks if a string is a valid bech32-encoded LNURL (LUD-01).

ts
lnurl.isLnurl('lnurl1dp68gurn8ghj7...') // true
lnurl.isLnurl('not-an-lnurl')            // false

lnurl.resolveUrl

ts
function resolveUrl(input: string): { url: string; tag?: string }

Resolves an LNURL to a plain URL (LUD-17). Accepts bech32-encoded LNURLs, scheme-prefixed URLs (lnurlp://, lnurlw://, lnurlc://, keyauth://), or plain https:// URLs as passthrough.

ts
// Bech32 LNURL
const { url } = lnurl.resolveUrl('lnurl1dp68gurn8ghj7...')
// { url: 'https://service.com/api?q=3fc3645b' }

// LUD-17 scheme prefix
const result = lnurl.resolveUrl('lnurlp://service.com/.well-known/lnurlp/alice')
// { url: 'https://service.com/.well-known/lnurlp/alice', tag: 'payRequest' }

// Plain URL passthrough
const plain = lnurl.resolveUrl('https://service.com/lnurl')
// { url: 'https://service.com/lnurl' }

lnurl.parseLnurlMetadata

ts
function parseLnurlMetadata(metadata: string): LnurlMetadata

Parses the LNURL metadata JSON string into structured data (LUD-06/20). Metadata is a JSON-encoded array of [mime-type, content] tuples.

ts
const meta = lnurl.parseLnurlMetadata(payRequest.metadata)
console.log(meta.plainText)  // 'Pay to alice@service.com'
console.log(meta.longDesc)   // Long description (LUD-20), if present
console.log(meta.image)      // { type: 'image/png', data: 'base64...' }
console.log(meta.entries)    // [['text/plain', 'Pay to alice@service.com'], ...]

lnurl.fetchPayRequest

ts
function fetchPayRequest(input: string): Promise<PayRequestResponse>

Fetches a pay request from an LNURL endpoint (LUD-06). Accepts bech32 LNURL, scheme-prefixed URL, or plain URL.

ts
const payRequest = await lnurl.fetchPayRequest('lnurl1dp68gurn8ghj7...')
console.log(payRequest.minSendable)    // 1000 (msats)
console.log(payRequest.maxSendable)    // 1000000000 (msats)
console.log(payRequest.commentAllowed) // 144 (LUD-12)
console.log(payRequest.payerData)      // PayerDataSpec (LUD-18)
console.log(payRequest.allowsNostr)    // true (for zaps)

lnurl.requestInvoice

ts
function requestInvoice(
  payRequest: PayRequestResponse,
  amountMsats: number,
  opts?: RequestInvoiceOptions,
): Promise<PayRequestCallbackResponse>

Requests an invoice from a pay request callback (LUD-06). Supports comments (LUD-12), payer data (LUD-18), and nostr zap requests. Throws if the amount is outside the allowed range.

ts
const payRequest = await lnurl.fetchPayRequest('lnurl1dp68gurn8ghj7...')

// Simple invoice request
const { pr, successAction } = await lnurl.requestInvoice(payRequest, 10000)

// With comment (LUD-12) and payer data (LUD-18)
const response = await lnurl.requestInvoice(payRequest, 50000, {
  comment: 'Great work!',
  payerData: {
    name: 'Alice',
    email: 'alice@example.com',
  },
})

console.log(response.pr)            // BOLT-11 invoice string
console.log(response.successAction) // SuccessAction, if any
console.log(response.verify)        // LUD-21 verify URL, if any

lnurl.fetchWithdrawRequest

ts
function fetchWithdrawRequest(input: string): Promise<WithdrawRequestResponse>

Fetches a withdraw request from an LNURL endpoint (LUD-03).

ts
const withdraw = await lnurl.fetchWithdrawRequest('lnurl1dp68gurn8ghj7...')
console.log(withdraw.minWithdrawable) // 1000 (msats)
console.log(withdraw.maxWithdrawable) // 500000 (msats)
console.log(withdraw.k1)              // server-provided unique identifier

lnurl.submitWithdrawRequest

ts
function submitWithdrawRequest(
  withdrawRequest: WithdrawRequestResponse,
  invoice: string,
  opts?: { pin?: string },
): Promise<void>

Submits a withdraw request with a BOLT-11 invoice (LUD-03). The service will pay the provided invoice.

The callback URL is built with the URL API, so k1 and the invoice are properly encoded and any query parameters already on the callback are preserved.

ts
const withdraw = await lnurl.fetchWithdrawRequest('lnurl1dp68gurn8ghj7...')
const invoice = 'lnbc10u1p...' // your BOLT-11 invoice
await lnurl.submitWithdrawRequest(withdraw, invoice)

Bolt Card PIN (LUD-24)

When the amount exceeds withdrawRequest.pinLimit, the service requires the card PIN:

ts
if (withdraw.pinLimit !== undefined && amountMsats > withdraw.pinLimit) {
  await lnurl.submitWithdrawRequest(withdraw, invoice, { pin: userPin })
} else {
  await lnurl.submitWithdrawRequest(withdraw, invoice)
}

WARNING

The PIN travels as a plaintext query parameter, so supplying one requires an https: callback - anything else throws INSECURE_PIN_CALLBACK. The value is also scrubbed to pin=*** in any error this function throws, because some fetch implementations embed the full request URL in their error message.

lnurl.validateVerifyUrl

ts
function validateVerifyUrl(verifyUrl: string, callbackUrl: string): string

Validates that a verify URL handed back by an LNURL-pay service is safe to poll: it must be https: and live on the same host as the pay request's own callback. The comparison is port-insensitive, since services routinely publish the callback and the verify endpoint on different ports of the same host.

Fails closed - throws LnurlError rather than returning false. Returns the verify URL unchanged so it can be used inline.

ts
lnurl.validateVerifyUrl('https://pay.example:8443/verify/1', 'https://pay.example/cb')
// ok - same host

lnurl.validateVerifyUrl('http://pay.example/verify/1', 'https://pay.example/cb')
// throws INSECURE_VERIFY_URL

lnurl.validateVerifyUrl('https://evil.example/track', 'https://pay.example/cb')
// throws UNRELATED_VERIFY_URL

verifyPayment calls this for you when given a callback; use it directly if you poll the verify URL yourself.

lnurl.parseSuccessAction

ts
function parseSuccessAction(raw: unknown): SuccessAction

Parses and validates a success action object (LUD-09). Returns a typed SuccessAction or throws on invalid input.

ts
const action = lnurl.parseSuccessAction(callbackResponse.successAction)
switch (action.tag) {
  case 'message':
    console.log(action.message)
    break
  case 'url':
    console.log(action.description, action.url)
    break
  case 'aes':
    // Decrypt with preimage (see decryptAesSuccessAction)
    break
}

lnurl.decryptAesSuccessAction

ts
function decryptAesSuccessAction(
  action: Extract<SuccessAction, { tag: 'aes' }>,
  preimageHex: string,
): Promise<string>

Decrypts an AES-encrypted success action using the payment preimage (LUD-10). Uses AES-256-CBC with PKCS5 padding via the Web Crypto API. The preimage must be 32 bytes (64 hex characters).

ts
const response = await lnurl.requestInvoice(payRequest, 10000)

// After payment is complete and you have the preimage:
if (response.successAction?.tag === 'aes') {
  const plaintext = await lnurl.decryptAesSuccessAction(
    response.successAction,
    'abcdef0123456789...',  // 64-char hex preimage
  )
  console.log(plaintext) // decrypted message from the service
}

lnurl.verifyPayment

ts
function verifyPayment(
  verifyUrl: string,
  callback?: string | PayRequestResponse,
): Promise<VerifyResponse>

Polls a verify URL to check if a payment has been settled (LUD-21).

Always pass the callback

The verify URL comes from the service, not from your wallet. Pass the originating pay request (or its callback URL) as the second argument so the URL is validated with validateVerifyUrl before it is fetched. Without it a malicious or compromised LNURL-pay service can hand back an arbitrary URL that your wallet then GETs after every payment - a third-party tracking and SSRF primitive.

With no callback supplied, only non-HTTP schemes are refused.

ts
const response = await lnurl.requestInvoice(payRequest, 10000)

// After paying the invoice, check settlement status
if (response.verify) {
  const status = await lnurl.verifyPayment(response.verify, payRequest)
  console.log(status.settled)  // true if payment is confirmed
  console.log(status.preimage) // payment preimage hex string
  console.log(status.pr)       // the original invoice
}

How It Works

  • LUD-01 defines bech32 encoding for LNURL strings, making URLs shareable as QR codes
  • LUD-03 allows services to push sats to a user's wallet by providing an invoice to a withdraw endpoint
  • LUD-06 is the core pay request flow: fetch service parameters, then request an invoice for a specific amount
  • LUD-09/10 define success actions the wallet should perform after payment (show message, open URL, or decrypt AES content)
  • LUD-12 allows attaching a text comment to a payment, bounded by commentAllowed length
  • LUD-17 introduces human-readable scheme prefixes (lnurlp://, lnurlw://) as alternatives to bech32
  • LUD-18 enables the payer to optionally share identity information (name, email, pubkey) with the service
  • LUD-20 extends metadata with a text/long-desc entry for richer service descriptions
  • LUD-21 provides a polling endpoint to verify whether a payment has settled

Released under the MIT License.