From ef868e4d1a77cb6cf4ab27a3fcac1ed3887358af Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Wed, 29 Jul 2026 23:26:22 +0200 Subject: [PATCH] feat: allow a webhook on outgoing payments POST /api/v1/payments accepts a `webhook` field, but on the outgoing branch (out: true) the value was silently discarded: the route did not forward it, pay_invoice() had no parameter for it, and the CreatePayment it builds left the field unset. Callers got a 200, webhook_status stayed null, and no request was ever sent. The rest was already in place. CreatePayment.webhook exists, apipayments already stores webhook and webhook_status for outgoing rows, and the dispatch path is direction agnostic: _pay_external_invoice and _pay_internal_invoice both call _send_payment_notification_in_background, and send_payment_notification ends with a dispatch_webhook call gated on payment.webhook. The only reason an outgoing payment never fired a webhook is that payment.webhook was always None. Closes #4112. --- lnbits/core/services/payments.py | 2 + lnbits/core/views/payment_api.py | 3 ++ tests/api/test_api.py | 32 ++++++++++++ tests/unit/test_pay_invoice.py | 85 ++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+) diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 38439c876..4d660fabd 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -65,6 +65,7 @@ async def pay_invoice( tag: str = "", labels: list[str] | None = None, external_id: str | None = None, + webhook: str | None = None, conn: Connection | None = None, ) -> Payment: if settings.lnbits_only_allow_incoming_payments: @@ -99,6 +100,7 @@ async def pay_invoice( extra=extra, labels=labels, external_id=external_id, + webhook=webhook, ) async with db.reuse_conn(conn) if conn else db.connect() as new_conn: diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index 5239c7454..ea4ecccea 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -256,6 +256,8 @@ async def api_all_payments_paginated( `amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds already in the authorized account, specify `out: true` and use the `bolt11` field to supply the BOLT11 invoice to be paid. + The optional `webhook` field is honoured in both directions: LNbits POSTs + the payment to that URL once the payment reaches `success`. """, status_code=HTTPStatus.CREATED, responses={ @@ -281,6 +283,7 @@ async def api_payments_create( extra=invoice_data.extra, labels=invoice_data.labels, external_id=invoice_data.external_id, + webhook=invoice_data.webhook, ) return payment diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 2e111360f..d201f23c6 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -307,6 +307,38 @@ async def test_pay_invoice( # assert payment.payment_hash == invoice["payment_hash"] +# check POST /api/v1/payments: the webhook is kept on an outgoing payment +@pytest.mark.anyio +async def test_pay_invoice_with_webhook( + client, + inkey_headers_to, + adminkey_headers_from, + mocker: MockerFixture, +): + mocker.patch( + "lnbits.core.services.notifications.dispatch_webhook", + AsyncMock(return_value=None), + ) + + invoice_data = await get_random_invoice_data() + create_response = await client.post( + "/api/v1/payments", json=invoice_data, headers=inkey_headers_to + ) + assert create_response.status_code < 300 + + webhook_url = "http://test.404.lnbits.com" + data = { + "out": True, + "bolt11": create_response.json()["bolt11"], + "webhook": webhook_url, + } + response = await client.post( + "/api/v1/payments", json=data, headers=adminkey_headers_from + ) + assert response.status_code < 300 + assert response.json()["webhook"] == webhook_url + + # check GET /api/v1/payments/: payment status @pytest.mark.anyio async def test_check_payment_without_key(client, invoice: Payment): diff --git a/tests/unit/test_pay_invoice.py b/tests/unit/test_pay_invoice.py index eaa481daf..9ee8bb912 100644 --- a/tests/unit/test_pay_invoice.py +++ b/tests/unit/test_pay_invoice.py @@ -519,6 +519,91 @@ async def test_pay_external_invoice_success( assert ws_notification.call_count == 1, "Websocket notification sent." +@pytest.mark.anyio +async def test_pay_external_invoice_with_webhook( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2105 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002105" + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock( + return_value=PaymentResponse( + ok=True, checking_id=external_invoice.checking_id, preimage=preimage + ) + ), + ) + dispatch_webhook_mock = mocker.patch( + "lnbits.core.services.notifications.dispatch_webhook", + AsyncMock(return_value=None), + ) + + webhook_url = "http://test.404.lnbits.com" + payment = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + webhook=webhook_url, + ) + + _payment = await get_standalone_payment(payment.payment_hash) + assert _payment + assert _payment.status == PaymentState.SUCCESS.value + assert _payment.amount == -invoice_amount * 1000 + assert _payment.webhook == webhook_url, "Webhook is stored on the outgoing payment." + + await asyncio.sleep(1) + + assert ( + dispatch_webhook_mock.call_count == 1 + ), "Webhook dispatched for the outgoing payment." + dispatched_payment = dispatch_webhook_mock.call_args_list[0][0][0] + assert dispatched_payment.payment_hash == payment.payment_hash + assert dispatched_payment.amount < 0, "Dispatched payment is the outgoing one." + assert dispatched_payment.webhook == webhook_url + + +@pytest.mark.anyio +async def test_pay_external_invoice_without_webhook_does_not_dispatch( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2106 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002106" + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock( + return_value=PaymentResponse( + ok=True, checking_id=external_invoice.checking_id, preimage=preimage + ) + ), + ) + dispatch_webhook_mock = mocker.patch( + "lnbits.core.services.notifications.dispatch_webhook", + AsyncMock(return_value=None), + ) + + payment = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + _payment = await get_standalone_payment(payment.payment_hash) + assert _payment + assert _payment.status == PaymentState.SUCCESS.value + assert _payment.webhook is None + + await asyncio.sleep(1) + + assert dispatch_webhook_mock.call_count == 0, "No webhook, no dispatch." + + @pytest.mark.anyio async def test_retry_pay_success( from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet