Skip to content

Bug: Lightning address callback URL contains malformed webhook_data, breaking zaps #126

@santyr

Description

@santyr

Description

When accessing a lightning address via /.well-known/lnurlp/<username>, the returned callback URL contains a malformed webhook_data parameter even when no webhook is configured. This breaks LNURL-pay flows (especially Nostr zaps) because clients append ?amount=... to a URL that already has query parameters, resulting in double question marks.

Steps to Reproduce

  1. Create a pay link with a username (lightning address) and no webhook URL configured
  2. Query the lightning address endpoint:
    curl -s "https://example.com/.well-known/lnurlp/username" | jq .
  3. Observe the callback URL contains unexpected query params

Expected Behavior

{
  "callback": "https://example.com/lnurlp/api/v1/lnurl/cb/xxxxxx",
  ...
}

Actual Behavior

{
  "callback": "https://example.com/lnurlp/api/v1/lnurl/cb/xxxxxx?webhook_data=alias%3D%27webhook_data%27+extra%3D%7B%7D",
  ...
}

When a client (e.g., Primal, Damus) tries to pay, it appends ?amount=10000&nostr=... to this URL, resulting in:

/lnurl/cb/xxxxxx?webhook_data=alias%3D%27webhook_data%27+extra%3D%7B%7D?amount=10000&nostr=...

The double ? causes FastAPI to parse everything as one malformed parameter, returning:

RequestValidationError: [{'loc': ('query', 'amount'), 'msg': 'field required', 'type': 'value_error.missing'}]

Root Cause

In views_lnurl.py, the lnaddress() function calls api_lnurl_response() directly as a Python function without passing the webhook_data argument:

# Line 201-208
@lnurlp_lnurl_router.get("/api/v1/well-known/{username}")
async def lnaddress(
    username: str, request: Request
) -> LnurlPayResponse | LnurlErrorResponse:
    address_data = await get_address_data(username)
    if not address_data:
        return LnurlErrorResponse(reason="Lightning address not found.")
    return await api_lnurl_response(request, address_data.id)  # <-- missing webhook_data=None

The api_lnurl_response() function signature uses Query(None) as a default:

# Line 152-153
async def api_lnurl_response(
    request: Request, link_id: str, webhook_data: str | None = Query(None)
) -> LnurlPayResponse:

When called as a regular Python function (not via HTTP request), Query(None) doesn't resolve to None—it remains a FastAPI FieldInfo object. This object is truthy, so the check on line 170 passes:

# Line 169-171
url = request.url_for("lnurlp.api_lnurl_callback", link_id=link.id)
if webhook_data:  # FieldInfo is truthy!
    url = url.include_query_params(webhook_data=webhook_data)  # str(FieldInfo) = "alias='webhook_data' extra={}"

Proposed Fix

Option 1: Explicit None (minimal change)

@lnurlp_lnurl_router.get("/api/v1/well-known/{username}")
async def lnaddress(
    username: str, request: Request
) -> LnurlPayResponse | LnurlErrorResponse:
    address_data = await get_address_data(username)
    if not address_data:
        return LnurlErrorResponse(reason="Lightning address not found.")
    return await api_lnurl_response(request, address_data.id, webhook_data=None)

Option 2: Use Annotated syntax (recommended)

This separates the FastAPI metadata from the default value, making the function safe to call directly:

from typing import Annotated

@lnurlp_lnurl_router.get(
    "/api/v1/lnurl/{link_id}",
    name="lnurlp.api_lnurl_response.deprecated",
    deprecated=True,
)
@lnurlp_lnurl_router.get(
    "/{link_id}",
    name="lnurlp.api_lnurl_response",
)
async def api_lnurl_response(
    request: Request,
    link_id: str,
    webhook_data: Annotated[str | None, Query()] = None,
) -> LnurlPayResponse:

Impact

  • All lightning address zaps fail with 400 errors
  • Regular LNURL-pay (non-lightning-address) works fine because it hits the endpoint via HTTP where FastAPI resolves Query(None) correctly
  • Affects any client that uses the standard LNURL-pay flow (Primal, Damus, Zeus, etc.)

Environment

  • LNbits with lnurlp extension
  • FastAPI/Pydantic backend

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions